remote-claude 0.2.8 → 0.2.10

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.
@@ -850,6 +850,18 @@ def _build_session_list_elements(sessions: List[Dict], current_session: Optional
850
850
  "action": "list_disband_group", "session": name
851
851
  }}]
852
852
  })
853
+ right_buttons.append({
854
+ "tag": "button",
855
+ "text": {"tag": "plain_text", "content": "🗑️ 关闭"},
856
+ "type": "danger",
857
+ "confirm": {
858
+ "title": {"tag": "plain_text", "content": "确认关闭会话"},
859
+ "text": {"tag": "plain_text", "content": f"确定要关闭「{name}」吗?此操作不可撤销。"}
860
+ },
861
+ "behaviors": [{"type": "callback", "value": {
862
+ "action": "list_kill", "session": name
863
+ }}]
864
+ })
853
865
  elements.append({
854
866
  "tag": "column_set",
855
867
  "flex_mode": "none",
@@ -318,15 +318,20 @@ class LarkHandler:
318
318
  server_script = script_dir / "server" / "server.py"
319
319
  cmd = [sys.executable, str(server_script), session_name]
320
320
 
321
- logger.info(f"启动会话: {session_name}, 工作目录: {work_dir}, 命令: {cmd}")
321
+ logger.info(f"启动会话: {session_name}, 工作目录: {work_dir}, 命令: {' '.join(cmd)}")
322
322
  _track_stats('lark', 'cmd_start', session_name=session_name, chat_id=chat_id)
323
323
 
324
324
  try:
325
325
  import os as _os
326
+ from datetime import datetime as _datetime
326
327
  env = _os.environ.copy()
327
328
  env.pop("CLAUDECODE", None)
328
329
 
329
- subprocess.Popen(
330
+ from utils.session import USER_DATA_DIR
331
+ log_path = USER_DATA_DIR / "startup.log"
332
+ start_time = _datetime.now()
333
+
334
+ proc = subprocess.Popen(
330
335
  cmd,
331
336
  stdout=subprocess.DEVNULL,
332
337
  stderr=subprocess.DEVNULL,
@@ -335,13 +340,38 @@ class LarkHandler:
335
340
  env=env,
336
341
  )
337
342
 
343
+ def _read_log_since(since):
344
+ if not log_path.exists():
345
+ return ""
346
+ lines = []
347
+ for line in log_path.read_text(encoding="utf-8").splitlines():
348
+ try:
349
+ ts = _datetime.strptime(line[:23], "%Y-%m-%d %H:%M:%S.%f")
350
+ if ts >= since:
351
+ lines.append(line)
352
+ except ValueError:
353
+ if lines:
354
+ lines.append(line)
355
+ return "\n".join(lines)
356
+
338
357
  socket_path = get_socket_path(session_name)
339
- for _ in range(120):
358
+ for i in range(120):
340
359
  await asyncio.sleep(0.1)
341
360
  if socket_path.exists():
342
361
  break
362
+ if (i + 1) % 10 == 0:
363
+ elapsed = (i + 1) // 10
364
+ rc = proc.poll()
365
+ if rc is not None:
366
+ log_content = _read_log_since(start_time)
367
+ logger.warning(f"会话启动失败: server 进程已退出 (exitcode={rc}, elapsed={elapsed}s)\n{log_content}")
368
+ await card_service.send_text(chat_id, f"错误: Server 进程意外退出 (code={rc})\n\n{log_content}")
369
+ return
370
+ logger.info(f"等待 server socket... ({elapsed}s)")
343
371
  else:
344
- await card_service.send_text(chat_id, "错误: 会话启动超时")
372
+ log_content = _read_log_since(start_time)
373
+ logger.error(f"会话启动超时 (12s), session={session_name}\n{log_content}")
374
+ await card_service.send_text(chat_id, f"错误: 会话启动超时 (12s)\n\n{log_content}")
345
375
  return
346
376
 
347
377
  ok = await self._attach(chat_id, session_name)
@@ -401,7 +431,8 @@ class LarkHandler:
401
431
  logger.error(f"启动并创建群聊失败: {e}")
402
432
  await card_service.send_text(chat_id, f"操作失败:{e}")
403
433
 
404
- async def _cmd_kill(self, user_id: str, chat_id: str, args: str):
434
+ async def _cmd_kill(self, user_id: str, chat_id: str, args: str,
435
+ message_id: Optional[str] = None):
405
436
  """终止会话"""
406
437
  from utils.session import cleanup_session, tmux_session_exists, tmux_kill_session
407
438
 
@@ -415,6 +446,13 @@ class LarkHandler:
415
446
  await card_service.send_text(chat_id, f"错误: 会话 '{session_name}' 不存在")
416
447
  return
417
448
 
449
+ # 解散绑定该会话的专属群聊(必须在断开连接之前,否则 _chat_bindings 已被清除)
450
+ for cid in list(self._group_chat_ids):
451
+ if self._chat_bindings.get(cid) == session_name:
452
+ ok, err = await self._disband_group_via_api(cid)
453
+ if not ok:
454
+ logger.warning(f"关闭会话时解散群 {cid} 失败: {err}")
455
+
418
456
  # 断开所有连接到此会话的 chat
419
457
  for cid, sname in list(self._chat_sessions.items()):
420
458
  if sname == session_name:
@@ -436,6 +474,7 @@ class LarkHandler:
436
474
  cleanup_session(session_name)
437
475
 
438
476
  await card_service.send_text(chat_id, f"✅ 会话 '{session_name}' 已终止")
477
+ await self._cmd_list(user_id, chat_id, message_id=message_id)
439
478
 
440
479
  async def _handle_list_detach(self, user_id: str, chat_id: str,
441
480
  message_id: Optional[str] = None):
@@ -684,17 +723,8 @@ class LarkHandler:
684
723
  logger.error(f"创建群失败: {e}")
685
724
  await card_service.send_text(chat_id, f"创建群失败:{e}")
686
725
 
687
- async def _cmd_disband_group(self, user_id: str, chat_id: str, session_name: str,
688
- message_id: Optional[str] = None):
689
- """解散与指定会话绑定的专属群聊"""
690
- group_chat_id = next(
691
- (cid for cid, sname in self._chat_bindings.items() if sname == session_name and cid.startswith("oc_")),
692
- None
693
- )
694
- if not group_chat_id:
695
- await card_service.send_text(chat_id, f"会话 '{session_name}' 没有绑定群聊")
696
- return
697
-
726
+ async def _disband_group_via_api(self, group_chat_id: str) -> tuple:
727
+ """调用飞书 API 解散群聊,返回 (ok: bool, err_msg: str)"""
698
728
  import json as _json
699
729
  import urllib.request
700
730
  import urllib.error
@@ -709,9 +739,6 @@ class LarkHandler:
709
739
  ), timeout=10
710
740
  )
711
741
  token = _json.loads(token_resp.read())["tenant_access_token"]
712
-
713
- feishu_ok = False
714
- feishu_msg = ""
715
742
  try:
716
743
  disband_resp = urllib.request.urlopen(
717
744
  urllib.request.Request(
@@ -721,17 +748,33 @@ class LarkHandler:
721
748
  ), timeout=10
722
749
  )
723
750
  disband_data = _json.loads(disband_resp.read())
724
- feishu_ok = disband_data.get("code") == 0
725
- feishu_msg = disband_data.get("msg", "")
751
+ if disband_data.get("code") == 0:
752
+ return True, ""
753
+ return False, disband_data.get("msg", "")
726
754
  except urllib.error.HTTPError as e:
727
755
  err_body = e.read().decode("utf-8", errors="replace")
728
756
  try:
729
757
  err_data = _json.loads(err_body)
730
- feishu_ok = False
731
- feishu_msg = f"code={err_data.get('code')} {err_data.get('msg', '')}"
758
+ return False, f"code={err_data.get('code')} {err_data.get('msg', '')}"
732
759
  except Exception:
733
- feishu_ok = False
734
- feishu_msg = f"HTTP {e.code}"
760
+ return False, f"HTTP {e.code}"
761
+ except Exception as e:
762
+ return False, str(e)
763
+
764
+ async def _cmd_disband_group(self, user_id: str, chat_id: str, session_name: str,
765
+ message_id: Optional[str] = None):
766
+ """解散与指定会话绑定的专属群聊"""
767
+ group_chat_id = next(
768
+ (cid for cid, sname in self._chat_bindings.items() if sname == session_name and cid.startswith("oc_")),
769
+ None
770
+ )
771
+ if not group_chat_id:
772
+ await card_service.send_text(chat_id, f"会话 '{session_name}' 没有绑定群聊")
773
+ return
774
+
775
+ try:
776
+ feishu_ok, feishu_msg = await self._disband_group_via_api(group_chat_id)
777
+ if not feishu_ok:
735
778
  logger.error(f"解散群 API 失败: {feishu_msg}")
736
779
 
737
780
  # 无论 Feishu delete 是否成功,都清理本地绑定
@@ -169,6 +169,13 @@ def handle_card_action(event: P2CardActionTrigger) -> P2CardActionTriggerRespons
169
169
  asyncio.create_task(handler._cmd_disband_group(user_id, chat_id, session_name, message_id=message_id))
170
170
  return None
171
171
 
172
+ # 列表卡片:关闭会话
173
+ if action_type == "list_kill":
174
+ session_name = action_value.get("session", "")
175
+ print(f"[Lark] list_kill: session={session_name}")
176
+ asyncio.create_task(handler._cmd_kill(user_id, chat_id, session_name, message_id=message_id))
177
+ return None
178
+
172
179
  # 目录卡片:进入子目录(继续浏览,就地更新原卡片)
173
180
  if action_type == "dir_browse":
174
181
  path = action_value.get("path", "")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "remote-claude",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
4
4
  "description": "双端共享 Claude CLI 工具",
5
5
  "bin": {
6
6
  "remote-claude": "bin/remote-claude",
@@ -33,7 +33,7 @@
33
33
  "license": "MIT",
34
34
  "repository": {
35
35
  "type": "git",
36
- "url": "git+https://github.com/yuyangzi/remote_claude.git"
36
+ "url": "git+https://github.com/yyzybb537/remote_claude.git"
37
37
  },
38
38
  "keywords": [
39
39
  "claude",
package/remote_claude.py CHANGED
@@ -11,11 +11,13 @@ Remote Claude - 双端共享 Claude CLI 工具
11
11
  """
12
12
 
13
13
  import argparse
14
+ import logging
14
15
  import os
15
16
  import sys
16
17
  import subprocess
17
18
  import time
18
19
  import signal
20
+ from datetime import datetime
19
21
  from pathlib import Path
20
22
 
21
23
  # 确保项目根目录在 sys.path 中,以便 import client / server 子模块
@@ -31,7 +33,7 @@ from utils.session import (
31
33
  is_lark_running, get_lark_pid, get_lark_status, get_lark_pid_file,
32
34
  save_lark_status, cleanup_lark,
33
35
  USER_DATA_DIR, ensure_user_data_dir, get_lark_log_file,
34
- get_env_snapshot_path
36
+ get_env_snapshot_path,
35
37
  )
36
38
 
37
39
 
@@ -91,7 +93,22 @@ def cmd_start(args):
91
93
 
92
94
  server_cmd = f"{env_prefix}uv run --project '{SCRIPT_DIR}' python3 '{server_script}'{debug_flag}{debug_verbose_flag}{cli_type_flag} -- '{session_name}' {claude_args_str}"
93
95
 
94
- print(f"启动会话: {session_name}")
96
+ # 配置启动日志(写文件 + stdout)
97
+ _log_path = USER_DATA_DIR / "startup.log"
98
+ _start_logger = logging.getLogger('Start')
99
+ if not _start_logger.handlers:
100
+ _handler_file = logging.FileHandler(_log_path, encoding="utf-8")
101
+ _handler_file.setFormatter(logging.Formatter(
102
+ "%(asctime)s.%(msecs)03d [%(name)s] %(levelname)s %(message)s",
103
+ datefmt="%Y-%m-%d %H:%M:%S",
104
+ ))
105
+ _start_logger.addHandler(_handler_file)
106
+ _start_logger.setLevel(logging.INFO)
107
+ _start_logger.propagate = False
108
+
109
+ start_time = datetime.now()
110
+ _start_logger.info(f"启动会话: {session_name}")
111
+ _start_logger.info(f"server_cmd: {server_cmd}")
95
112
 
96
113
  # 创建 tmux 会话,运行 server(detached,仅后台)
97
114
  if not tmux_create_session(session_name, server_cmd, detached=True):
@@ -100,12 +117,30 @@ def cmd_start(args):
100
117
 
101
118
  # 等待 server 启动
102
119
  socket_path = get_socket_path(session_name)
103
- for _ in range(50): # 最多等待 5 秒
120
+ for i in range(50): # 最多等待 5 秒
104
121
  if socket_path.exists():
105
122
  break
106
123
  time.sleep(0.1)
124
+ if (i + 1) % 10 == 0:
125
+ elapsed = (i + 1) // 10
126
+ print(f"等待 Server 启动... ({elapsed}s)")
107
127
  else:
108
- print("错误: Server 启动超时")
128
+ print("错误: Server 启动超时 (5s)")
129
+ # 过滤出本次启动后的日志行
130
+ if _log_path.exists():
131
+ lines = []
132
+ for line in _log_path.read_text(encoding="utf-8").splitlines():
133
+ try:
134
+ ts = datetime.strptime(line[:23], "%Y-%m-%d %H:%M:%S.%f")
135
+ if ts >= start_time:
136
+ lines.append(line)
137
+ except ValueError:
138
+ if lines: # 多行日志的续行,附到上一条
139
+ lines.append(line)
140
+ if lines:
141
+ print(f"--- Server 日志 ({_log_path}) ---")
142
+ print("\n".join(lines))
143
+ print("-------------------")
109
144
  tmux_kill_session(session_name)
110
145
  return 1
111
146
 
package/server/server.py CHANGED
@@ -9,6 +9,7 @@ Proxy Server
9
9
  """
10
10
 
11
11
  import asyncio
12
+ import logging
12
13
  import os
13
14
  import pty
14
15
  import signal
@@ -35,9 +36,12 @@ from utils.protocol import (
35
36
  )
36
37
  from utils.session import (
37
38
  get_socket_path, get_pid_file, ensure_socket_dir,
38
- generate_client_id, cleanup_session, _safe_filename, get_env_file
39
+ generate_client_id, cleanup_session, _safe_filename, get_env_file,
40
+ SOCKET_DIR
39
41
  )
40
42
 
43
+ logger = logging.getLogger('Server')
44
+
41
45
  # 加载用户 .env 配置(支持 CLAUDE_COMMAND 等)
42
46
  try:
43
47
  from dotenv import load_dotenv
@@ -64,6 +68,11 @@ class _FrameObs:
64
68
  status_line: Optional[object] # 本帧的 StatusLine(None=无)
65
69
  block_blink: bool = False # 本帧最后一个 OutputBlock 是否 is_streaming=True
66
70
  has_background_agents: bool = False # 底部栏是否有后台 agent 信息
71
+ # 用于字符变化检测(增强闪烁判断)
72
+ last_ob_start_row: int = -1 # 最后 OutputBlock 的起始行号(跨帧识别同一 block)
73
+ last_ob_indicator_char: str = '' # 指示符字符值(pyte char.data)
74
+ last_ob_indicator_fg: str = '' # 指示符前景色(pyte char.fg)
75
+ last_ob_indicator_bold: bool = False # 指示符 bold 属性(影响显示亮度)
67
76
 
68
77
 
69
78
  @dataclass
@@ -154,6 +163,9 @@ class OutputWatcher:
154
163
 
155
164
  def feed(self, data: bytes):
156
165
  self._renderer.feed(data) # 直接喂持久化 screen,不再缓存原始字节
166
+ # 诊断日志:记录 PTY 数据到达
167
+ if data:
168
+ logger.debug(f"[diag-feed] len={len(data)} data={data[:50]!r}")
157
169
  try:
158
170
  loop = asyncio.get_running_loop()
159
171
  except RuntimeError:
@@ -185,6 +197,8 @@ class OutputWatcher:
185
197
 
186
198
  async def _flush(self):
187
199
  self._pending = False
200
+ # 诊断日志:记录 flush 触发时间和帧窗口大小
201
+ logger.debug(f"[diag-flush] ts={time.time():.6f} window_size={len(self._frame_window)}")
188
202
  try:
189
203
  from utils.components import StatusLine, BottomBar, Divider, OutputBlock, AgentPanelBlock, OptionBlock
190
204
 
@@ -224,10 +238,24 @@ class OutputWatcher:
224
238
  # 4a. 记录原始帧观测(必须用未平滑的原始值)
225
239
  last_ob_blink = False
226
240
  last_ob_content = ''
241
+ last_ob_start_row = -1
242
+ last_ob_indicator_char = ''
243
+ last_ob_indicator_fg = ''
244
+ last_ob_indicator_bold = False
227
245
  for b in reversed(visible_blocks):
228
246
  if isinstance(b, OutputBlock):
229
247
  last_ob_blink = b.is_streaming
230
248
  last_ob_content = b.content[:40]
249
+ last_ob_start_row = b.start_row
250
+ # 直接读 pyte screen buffer 获取原始字符属性(用于变化检测)
251
+ if b.start_row >= 0:
252
+ try:
253
+ char = self._renderer.screen.buffer[b.start_row][0]
254
+ last_ob_indicator_char = str(getattr(char, 'data', ''))
255
+ last_ob_indicator_fg = str(getattr(char, 'fg', ''))
256
+ last_ob_indicator_bold = bool(getattr(char, 'bold', False))
257
+ except (KeyError, IndexError):
258
+ pass
231
259
  break
232
260
  if last_ob_blink:
233
261
  _blink_log = open(f"/tmp/remote-claude/{self._session_name}_blink.log", "a")
@@ -241,6 +269,10 @@ class OutputWatcher:
241
269
  status_line=raw_status_line,
242
270
  block_blink=last_ob_blink,
243
271
  has_background_agents=getattr(raw_bottom_bar, 'has_background_agents', False) if raw_bottom_bar else False,
272
+ last_ob_start_row=last_ob_start_row,
273
+ last_ob_indicator_char=last_ob_indicator_char,
274
+ last_ob_indicator_fg=last_ob_indicator_fg,
275
+ last_ob_indicator_bold=last_ob_indicator_bold,
244
276
  ))
245
277
  cutoff = now - self.WINDOW_SECONDS
246
278
  while self._frame_window and self._frame_window[0].ts < cutoff:
@@ -262,9 +294,27 @@ class OutputWatcher:
262
294
  None
263
295
  )
264
296
 
265
- # 4c. block blink 平滑:窗口内任意帧有 blink → streaming
266
- # 修改 visible_blocks 中最后一个 OutputBlock(平滑后再合并)
297
+ # 4c. block blink 平滑:两种触发路径
298
+ # 路径1:窗口内任意帧有 pyte blink 属性
267
299
  window_block_active = any(o.block_blink for o in window_list)
300
+
301
+ # 路径2:窗口内同一 block 的指示符字符值/颜色/bold 有变化(增强闪烁判断)
302
+ if not window_block_active and last_ob_start_row >= 0:
303
+ same_block = [o for o in window_list if o.last_ob_start_row == last_ob_start_row]
304
+ if len(same_block) >= 2:
305
+ chars = {o.last_ob_indicator_char for o in same_block if o.last_ob_indicator_char}
306
+ fgs = {o.last_ob_indicator_fg for o in same_block if o.last_ob_indicator_fg}
307
+ bolds = {o.last_ob_indicator_bold for o in same_block}
308
+ if len(chars) > 1 or len(fgs) > 1 or len(bolds) > 1:
309
+ window_block_active = True
310
+ # 记录字符变化触发原因
311
+ _blink_log = open(f"/tmp/remote-claude/{self._session_name}_blink.log", "a")
312
+ _blink_log.write(
313
+ f"[{time.strftime('%H:%M:%S')}] char-change row={last_ob_start_row}"
314
+ f" chars={chars} fgs={fgs} bolds={bolds}\n"
315
+ )
316
+ _blink_log.close()
317
+
268
318
  if window_block_active:
269
319
  for b in reversed(visible_blocks):
270
320
  if isinstance(b, OutputBlock):
@@ -303,6 +353,16 @@ class OutputWatcher:
303
353
  layout_mode=self._parser.last_layout_mode,
304
354
  cli_type=self._cli_type,
305
355
  )
356
+ # 诊断日志:检测最终输出中是否有同时存在 status_line 和 SystemBlock 的情况
357
+ if display_status:
358
+ status_prefix = display_status.raw[:30] if hasattr(display_status, 'raw') else str(display_status)[:30]
359
+ has_systemblock_with_status = any(
360
+ b.__class__.__name__ == 'SystemBlock' and
361
+ hasattr(b, 'content') and status_prefix in b.content
362
+ for b in all_blocks
363
+ )
364
+ if has_systemblock_with_status:
365
+ logger.debug(f"[diag-output] BOTH status_line and SystemBlock present! status_line={status_prefix!r}")
306
366
  self.last_window = window
307
367
 
308
368
  # 7. 输出
@@ -342,7 +402,7 @@ class OutputWatcher:
342
402
  lines.append(f" ansi_raw={sl.ansi_raw[:120]!r}")
343
403
  lines.append(f" ansi_render: {sl.ansi_indicator} {sl.ansi_raw[:120]}\x1b[0m")
344
404
  else:
345
- lines.append(f"status_line: {sl.ansi_indicator} {sl.ansi_raw[:120]}\x1b[0m")
405
+ lines.append(f"status_line: {sl.ansi_raw[:120]}\x1b[0m")
346
406
  else:
347
407
  lines.append("status_line: None")
348
408
  # BottomBar
@@ -529,7 +589,7 @@ class ClientConnection:
529
589
  self.writer.write(data)
530
590
  await self.writer.drain()
531
591
  except Exception as e:
532
- print(f"[Server] 发送消息失败 ({self.client_id}): {e}")
592
+ logger.warning(f"发送消息失败 ({self.client_id}): {e}")
533
593
 
534
594
  async def read_message(self) -> Optional[Message]:
535
595
  """读取一条消息"""
@@ -540,7 +600,7 @@ class ClientConnection:
540
600
  try:
541
601
  return decode_message(line)
542
602
  except Exception as e:
543
- print(f"[Server] 解析消息失败: {e}")
603
+ logger.warning(f"解析消息失败: {e}")
544
604
  continue
545
605
 
546
606
  # 读取更多数据
@@ -608,6 +668,8 @@ class ProxyServer:
608
668
 
609
669
  async def start(self):
610
670
  """启动服务器"""
671
+ t0 = time.time()
672
+ logger.info(f"正在启动 (session={self.session_name})")
611
673
  ensure_socket_dir()
612
674
 
613
675
  # 清理旧的 socket 文件
@@ -615,12 +677,15 @@ class ProxyServer:
615
677
  self.socket_path.unlink()
616
678
 
617
679
  # 启动 PTY
680
+ t1 = time.time()
618
681
  self._start_pty()
682
+ logger.info(f"PTY 已启动 ({(time.time()-t1)*1000:.0f}ms)")
619
683
 
620
684
  # 写入 PID 文件
621
685
  self.pid_file.write_text(str(os.getpid()))
622
686
 
623
687
  # 启动 Unix Socket 服务器
688
+ t2 = time.time()
624
689
  self.server = await asyncio.start_unix_server(
625
690
  self._handle_client,
626
691
  path=str(self.socket_path)
@@ -628,7 +693,7 @@ class ProxyServer:
628
693
 
629
694
  self.running = True
630
695
  _track_stats('session', 'start', session_name=self.session_name)
631
- print(f"[Server] 已启动: {self.socket_path}")
696
+ logger.info(f"已启动: {self.socket_path} (Socket {(time.time()-t2)*1000:.0f}ms, 总计 {(time.time()-t0)*1000:.0f}ms)")
632
697
 
633
698
  # 启动 PTY 读取任务
634
699
  asyncio.create_task(self._read_pty())
@@ -664,8 +729,14 @@ class ProxyServer:
664
729
  try:
665
730
  with open(env_snapshot_path) as _f:
666
731
  _extra_env = _json.load(_f)
732
+ logger.info(f"环境快照已加载 ({len(_extra_env)} 个变量)")
667
733
  except Exception:
668
- pass
734
+ logger.warning("环境快照加载失败,使用当前进程环境")
735
+
736
+ # 提前计算命令(fork 后父子进程共享,方便父进程打印和子进程执行)
737
+ import shlex as _shlex
738
+ _cmd_parts = _shlex.split(self._get_effective_cmd())
739
+ _full_cmd = ' '.join(_cmd_parts + self.claude_args)
669
740
 
670
741
  try:
671
742
  pid, fd = pty.fork()
@@ -688,10 +759,25 @@ class ProxyServer:
688
759
  # 清除 tmux 标识变量(PTY 数据不经过 tmux,不应让 Claude CLI 误判终端环境)
689
760
  child_env.pop('TMUX', None)
690
761
  child_env.pop('TMUX_PANE', None)
691
- import shlex as _shlex
692
- _cmd_parts = _shlex.split(self._get_effective_cmd())
693
- os.execvpe(_cmd_parts[0], _cmd_parts + self.claude_args, child_env)
694
- os._exit(1) # execvpe 失败时兜底退出
762
+ try:
763
+ os.execvpe(_cmd_parts[0], _cmd_parts + self.claude_args, child_env)
764
+ except Exception as _e:
765
+ msg = f"启动失败: 命令 '{_cmd_parts[0]}' 无法执行: {_e}"
766
+ os.write(1, (msg + "\n").encode()) # 写到 PTY
767
+ # fork 后不能安全使用 logging,直接追加写日志文件
768
+ try:
769
+ import time as _t
770
+ _ts = _t.strftime("%Y-%m-%d %H:%M:%S")
771
+ _ms = int((_t.time() % 1) * 1000)
772
+ _log_line = f"{_ts}.{_ms:03d} [Server] ERROR {msg}\n"
773
+ _home = os.path.expanduser("~")
774
+ _log_file = os.path.join(_home, ".remote-claude", "startup.log")
775
+ with open(_log_file, "a", encoding="utf-8") as _f:
776
+ _f.write(_log_line)
777
+ except Exception:
778
+ pass
779
+ os._exit(127) # 127 = command not found (shell convention)
780
+ os._exit(1) # 理论上不可达
695
781
  else:
696
782
  # 父进程
697
783
  self.master_fd = fd
@@ -706,7 +792,10 @@ class ProxyServer:
706
792
  fcntl.fcntl(fd, fcntl.F_SETFL, flags | os.O_NONBLOCK)
707
793
 
708
794
  cli_label = self.cli_type.capitalize()
709
- print(f"[Server] {cli_label} 已启动 (PID: {pid}, PTY: {self.PTY_COLS}×{self.PTY_ROWS})")
795
+ logger.info(f"启动命令: {_full_cmd}")
796
+ logger.info(f"{cli_label} 已启动 (PID: {pid}, PTY: {self.PTY_COLS}×{self.PTY_ROWS})")
797
+
798
+ _COALESCE_MAX = 64 * 1024 # 64KB,防止单次广播过大
710
799
 
711
800
  async def _read_pty(self):
712
801
  """读取 PTY 输出并广播"""
@@ -714,15 +803,26 @@ class ProxyServer:
714
803
 
715
804
  while self.running and self.master_fd is not None:
716
805
  try:
717
- # 使用 asyncio 读取
806
+ # 第一次 read(在线程池中,可能阻塞等待数据)
718
807
  data = await loop.run_in_executor(
719
808
  None, self._read_pty_sync
720
809
  )
721
810
  if data:
811
+ # 贪婪合并:非阻塞读取紧接数据,合并为一次广播
812
+ buf = bytearray(data)
813
+ while len(buf) < self._COALESCE_MAX:
814
+ try:
815
+ more = os.read(self.master_fd, 4096)
816
+ if not more:
817
+ break
818
+ buf.extend(more)
819
+ except (BlockingIOError, OSError):
820
+ break
821
+ coalesced = bytes(buf)
722
822
  # 保存到历史
723
- self.history.append(data)
823
+ self.history.append(coalesced)
724
824
  # 广播给所有客户端
725
- await self._broadcast_output(data)
825
+ await self._broadcast_output(coalesced)
726
826
  elif data is None:
727
827
  # 暂时无数据(BlockingIOError),稍等继续
728
828
  await asyncio.sleep(0.01)
@@ -731,11 +831,19 @@ class ProxyServer:
731
831
  break
732
832
  except Exception as e:
733
833
  if self.running:
734
- print(f"[Server] 读取 PTY 错误: {e}")
834
+ logger.error(f"读取 PTY 错误: {e}")
735
835
  break
736
836
 
737
- # Claude 退出
738
- print("[Server] Claude 已退出")
837
+ # Claude 退出,获取 exit code 以便诊断
838
+ try:
839
+ _, status = os.waitpid(self.child_pid, os.WNOHANG)
840
+ if status != 0:
841
+ exit_code = os.waitstatus_to_exitcode(status)
842
+ logger.error(f"CLI 进程异常退出 (exit_code={exit_code})")
843
+ else:
844
+ logger.info("Claude 已退出")
845
+ except Exception:
846
+ logger.info("Claude 已退出")
739
847
  await self._shutdown()
740
848
 
741
849
  def _read_pty_sync(self) -> Optional[bytes]:
@@ -753,7 +861,7 @@ class ProxyServer:
753
861
  client = ClientConnection(client_id, reader, writer)
754
862
  self.clients[client_id] = client
755
863
 
756
- print(f"[Server] 客户端连接: {client_id}")
864
+ logger.info(f"客户端连接: {client_id}")
757
865
  _track_stats('session', 'attach', session_name=self.session_name)
758
866
 
759
867
  # 发送历史输出
@@ -769,12 +877,12 @@ class ProxyServer:
769
877
  break
770
878
  await self._handle_message(client_id, msg)
771
879
  except Exception as e:
772
- print(f"[Server] 客户端处理错误 ({client_id}): {e}")
880
+ logger.error(f"客户端处理错误 ({client_id}): {e}")
773
881
  finally:
774
882
  # 清理
775
883
  del self.clients[client_id]
776
884
  client.close()
777
- print(f"[Server] 客户端断开: {client_id}")
885
+ logger.info(f"客户端断开: {client_id}")
778
886
 
779
887
  async def _handle_message(self, client_id: str, msg: Message):
780
888
  """处理客户端消息"""
@@ -791,7 +899,7 @@ class ProxyServer:
791
899
  _track_stats('terminal', 'input', session_name=self.session_name,
792
900
  value=len(data))
793
901
  except Exception as e:
794
- print(f"[Server] 写入 PTY 错误: {e}")
902
+ logger.error(f"写入 PTY 错误: {e}")
795
903
 
796
904
  # 广播输入给其他客户端(飞书侧可以感知终端用户的输入内容)
797
905
  for cid, client in list(self.clients.items()):
@@ -811,7 +919,7 @@ class ProxyServer:
811
919
  winsize = struct.pack('HHHH', msg.rows, msg.cols, 0, 0)
812
920
  fcntl.ioctl(self.master_fd, termios.TIOCSWINSZ, winsize)
813
921
  except Exception as e:
814
- print(f"[Server] 调整终端大小错误: {e}")
922
+ logger.error(f"调整终端大小错误: {e}")
815
923
 
816
924
  async def _broadcast_output(self, data: bytes):
817
925
  """广播输出给所有客户端,同时喂给 OutputWatcher 生成快照"""
@@ -850,7 +958,7 @@ class ProxyServer:
850
958
  # 清理文件
851
959
  cleanup_session(self.session_name)
852
960
 
853
- print("[Server] 已关闭")
961
+ logger.info("已关闭")
854
962
 
855
963
 
856
964
  def run_server(session_name: str, claude_args: list = None,
@@ -890,7 +998,22 @@ if __name__ == "__main__":
890
998
  help="debug 日志输出完整诊断信息(indicator、repr 等)")
891
999
  args = parser.parse_args()
892
1000
 
1001
+ # 配置日志:写文件(供故障诊断)+ stdout(供 tmux attach 时查看)
1002
+ from utils.session import USER_DATA_DIR
1003
+ USER_DATA_DIR.mkdir(parents=True, exist_ok=True)
1004
+ _log_path = USER_DATA_DIR / "startup.log"
1005
+ logging.basicConfig(
1006
+ level=logging.DEBUG,
1007
+ format="%(asctime)s.%(msecs)03d [%(name)s] %(levelname)s %(message)s",
1008
+ datefmt="%Y-%m-%d %H:%M:%S",
1009
+ handlers=[
1010
+ logging.FileHandler(_log_path, encoding="utf-8"),
1011
+ logging.StreamHandler(sys.stdout),
1012
+ ],
1013
+ )
1014
+
893
1015
  claude_cmd = os.environ.get("CLAUDE_COMMAND", "claude")
1016
+ logger.info(f"CLAUDE_COMMAND={claude_cmd!r}")
894
1017
  run_server(args.session_name, args.claude_args, claude_cmd=claude_cmd,
895
1018
  cli_type=args.cli_type,
896
1019
  debug_screen=args.debug_screen, debug_verbose=args.debug_verbose)
package/utils/session.py CHANGED
@@ -101,6 +101,8 @@ def tmux_create_session(session_name: str, command: str, detached: bool = True)
101
101
  args.extend(["-x", "200", "-y", "50"]) # 默认大小
102
102
  args.append(command)
103
103
 
104
+ import logging as _logging
105
+ _logging.getLogger('Start').info(f"tmux_cmd: {' '.join(args)}")
104
106
  result = subprocess.run(args, capture_output=True)
105
107
  if result.returncode == 0:
106
108
  # 启用鼠标支持,允许在 tmux 窗口内用鼠标滚轮查看历史输出