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.
- package/lark_client/card_builder.py +12 -0
- package/lark_client/lark_handler.py +68 -25
- package/lark_client/main.py +7 -0
- package/package.json +2 -2
- package/remote_claude.py +39 -4
- package/server/server.py +148 -25
- package/utils/session.py +2 -0
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
688
|
-
|
|
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
|
-
|
|
725
|
-
|
|
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
|
-
|
|
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
|
-
|
|
734
|
-
|
|
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 是否成功,都清理本地绑定
|
package/lark_client/main.py
CHANGED
|
@@ -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.
|
|
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/
|
|
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
|
-
|
|
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
|
|
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
|
|
266
|
-
#
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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(
|
|
823
|
+
self.history.append(coalesced)
|
|
724
824
|
# 广播给所有客户端
|
|
725
|
-
await self._broadcast_output(
|
|
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
|
-
|
|
834
|
+
logger.error(f"读取 PTY 错误: {e}")
|
|
735
835
|
break
|
|
736
836
|
|
|
737
|
-
# Claude
|
|
738
|
-
|
|
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
|
-
|
|
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
|
-
|
|
880
|
+
logger.error(f"客户端处理错误 ({client_id}): {e}")
|
|
773
881
|
finally:
|
|
774
882
|
# 清理
|
|
775
883
|
del self.clients[client_id]
|
|
776
884
|
client.close()
|
|
777
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 窗口内用鼠标滚轮查看历史输出
|