remote-claude 0.1.6 → 0.2.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.
@@ -261,6 +261,7 @@ def _build_menu_button_row(session_name: Optional[str] = None, disconnected: boo
261
261
  "width": "auto",
262
262
  "elements": [{
263
263
  "tag": "button",
264
+ "name": "enter_submit",
264
265
  "text": {"tag": "plain_text", "content": "Enter ↵"},
265
266
  "type": "primary",
266
267
  "action_type": "form_submit",
@@ -290,6 +291,7 @@ def _build_menu_button_row(session_name: Optional[str] = None, disconnected: boo
290
291
  "width": "auto",
291
292
  "elements": [{
292
293
  "tag": "button",
294
+ "name": "enter_submit",
293
295
  "text": {"tag": "plain_text", "content": "Enter ↵"},
294
296
  "type": "primary",
295
297
  "action_type": "form_submit",
@@ -312,6 +314,7 @@ def _build_menu_button_row(session_name: Optional[str] = None, disconnected: boo
312
314
  ("Ctrl+O", {"action": "send_key", "key": "ctrl_o"}),
313
315
  ("Shift+Tab", {"action": "send_key", "key": "shift_tab"}),
314
316
  ("ESC", {"action": "send_key", "key": "esc"}),
317
+ ("(↹)×3", {"action": "send_key", "key": "shift_tab", "times": 3}),
315
318
  ]
316
319
 
317
320
  def _make_key_column(label, value):
@@ -773,58 +776,57 @@ def _build_session_list_elements(sessions: List[Dict], current_session: Optional
773
776
  btn_label = "进入会话"
774
777
  btn_type = "primary"
775
778
  btn_action = "list_attach"
776
- columns = [
777
- {
778
- "tag": "column",
779
- "width": "weighted",
780
- "weight": 5,
781
- "elements": [{"tag": "markdown", "content": header_text}]
782
- },
779
+ has_group = bool(session_groups and name in session_groups)
780
+
781
+ # 右列按钮(纵向堆叠)
782
+ right_buttons = [
783
783
  {
784
- "tag": "column",
785
- "width": "weighted",
786
- "weight": 2,
787
- "elements": [{
788
- "tag": "button",
789
- "text": {"tag": "plain_text", "content": btn_label},
790
- "type": btn_type,
791
- "behaviors": [{"type": "callback", "value": {
792
- "action": btn_action, "session": name
793
- }}]
794
- }]
784
+ "tag": "button",
785
+ "text": {"tag": "plain_text", "content": btn_label},
786
+ "type": btn_type,
787
+ "behaviors": [{"type": "callback", "value": {
788
+ "action": btn_action, "session": name
789
+ }}]
795
790
  },
796
791
  {
797
- "tag": "column",
798
- "width": "weighted",
799
- "weight": 2,
800
- "elements": [{
801
- "tag": "button",
802
- "text": {"tag": "plain_text", "content": "进入群聊" if (session_groups and name in session_groups) else "创建群聊"},
803
- "type": "default",
804
- "behaviors": [{"type": "open_url", "default_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[name]}"}]
805
- if (session_groups and name in session_groups) else
806
- [{"type": "callback", "value": {"action": "list_new_group", "session": name}}]
807
- }]
792
+ "tag": "button",
793
+ "text": {"tag": "plain_text", "content": "进入群聊" if has_group else "创建群聊"},
794
+ "type": "default",
795
+ "behaviors": [{"type": "open_url",
796
+ "default_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[name]}",
797
+ "android_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[name]}",
798
+ "ios_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[name]}",
799
+ "pc_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[name]}"}]
800
+ if has_group else
801
+ [{"type": "callback", "value": {"action": "list_new_group", "session": name}}]
808
802
  },
809
803
  ]
810
- if session_groups and name in session_groups:
811
- columns.append({
812
- "tag": "column",
813
- "width": "weighted",
814
- "weight": 2,
815
- "elements": [{
816
- "tag": "button",
817
- "text": {"tag": "plain_text", "content": "解散群聊"},
818
- "type": "danger",
819
- "behaviors": [{"type": "callback", "value": {
820
- "action": "list_disband_group", "session": name
821
- }}]
822
- }]
804
+ if has_group:
805
+ right_buttons.append({
806
+ "tag": "button",
807
+ "text": {"tag": "plain_text", "content": "解散群聊"},
808
+ "type": "danger",
809
+ "behaviors": [{"type": "callback", "value": {
810
+ "action": "list_disband_group", "session": name
811
+ }}]
823
812
  })
824
813
  elements.append({
825
814
  "tag": "column_set",
826
815
  "flex_mode": "none",
827
- "columns": columns
816
+ "columns": [
817
+ {
818
+ "tag": "column",
819
+ "width": "weighted",
820
+ "weight": 3,
821
+ "elements": [{"tag": "markdown", "content": header_text}]
822
+ },
823
+ {
824
+ "tag": "column",
825
+ "width": "weighted",
826
+ "weight": 2,
827
+ "elements": right_buttons
828
+ },
829
+ ]
828
830
  })
829
831
  elements.append({"tag": "hr"})
830
832
 
@@ -872,7 +874,7 @@ def _dir_session_name(path: str) -> str:
872
874
  return name or "session"
873
875
 
874
876
 
875
- def build_dir_card(target, entries: List[Dict], sessions: List[Dict], tree: bool = False, session_groups: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
877
+ def build_dir_card(target, entries: List[Dict], sessions: List[Dict], tree: bool = False, session_groups: Optional[Dict[str, str]] = None, page: int = 0) -> Dict[str, Any]:
876
878
  """构建目录浏览卡片
877
879
 
878
880
  顶层目录(depth==0)带两个操作按钮:
@@ -907,9 +909,17 @@ def build_dir_card(target, entries: List[Dict], sessions: List[Dict], tree: bool
907
909
  })
908
910
  elements.append({"tag": "hr"})
909
911
 
910
- cap = 20
912
+ PER_PAGE = 12
911
913
  total = len(entries)
912
- shown = entries[:cap]
914
+ if tree:
915
+ # tree 模式不分页,直接展示全部(已有 max_items 上限)
916
+ shown = entries
917
+ page = 0
918
+ total_pages = 1
919
+ else:
920
+ total_pages = max(1, (total + PER_PAGE - 1) // PER_PAGE)
921
+ page = max(0, min(page, total_pages - 1))
922
+ shown = entries[page * PER_PAGE : (page + 1) * PER_PAGE]
913
923
 
914
924
  for entry in shown:
915
925
  name = entry["name"]
@@ -921,6 +931,22 @@ def build_dir_card(target, entries: List[Dict], sessions: List[Dict], tree: bool
921
931
 
922
932
  if is_dir and depth == 0:
923
933
  auto_session = _dir_session_name(full_path)
934
+ group_btn = {
935
+ "tag": "button",
936
+ "text": {"tag": "plain_text", "content": "进入群聊" if (session_groups and auto_session in session_groups) else "创建群聊"},
937
+ "type": "default",
938
+ "behaviors": [{"type": "open_url",
939
+ "default_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[auto_session]}",
940
+ "android_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[auto_session]}",
941
+ "ios_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[auto_session]}",
942
+ "pc_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[auto_session]}"}]
943
+ if (session_groups and auto_session in session_groups) else
944
+ [{"type": "callback", "value": {
945
+ "action": "dir_new_group",
946
+ "path": full_path,
947
+ "session_name": auto_session
948
+ }}]
949
+ }
924
950
  elements.append({
925
951
  "tag": "column_set",
926
952
  "flex_mode": "none",
@@ -928,61 +954,77 @@ def build_dir_card(target, entries: List[Dict], sessions: List[Dict], tree: bool
928
954
  {
929
955
  "tag": "column",
930
956
  "width": "weighted",
931
- "weight": 3,
932
- "elements": [{"tag": "markdown", "content": f"{icon} **{name}**"}]
933
- },
934
- {
935
- "tag": "column",
936
- "width": "weighted",
937
- "weight": 2,
957
+ "weight": 4,
938
958
  "elements": [{
939
- "tag": "button",
940
- "text": {"tag": "plain_text", "content": "📂 进入"},
941
- "type": "default",
959
+ "tag": "interactive_container",
960
+ "width": "fill",
961
+ "height": "auto",
942
962
  "behaviors": [{"type": "callback", "value": {
943
963
  "action": "dir_browse", "path": full_path
944
- }}]
964
+ }}],
965
+ "elements": [{"tag": "markdown", "content": f"📁 **{name}**"}]
945
966
  }]
946
967
  },
947
968
  {
948
969
  "tag": "column",
949
970
  "width": "weighted",
950
971
  "weight": 2,
951
- "elements": [{
952
- "tag": "button",
953
- "text": {"tag": "plain_text", "content": "🚀 在此启动"},
954
- "type": "primary",
955
- "behaviors": [{"type": "callback", "value": {
956
- "action": "dir_start",
957
- "path": full_path,
958
- "session_name": auto_session
959
- }}]
960
- }]
961
- },
962
- {
963
- "tag": "column",
964
- "width": "weighted",
965
- "weight": 2,
966
- "elements": [{
967
- "tag": "button",
968
- "text": {"tag": "plain_text", "content": "进入群聊" if (session_groups and auto_session in session_groups) else "创建群聊"},
969
- "type": "default",
970
- "behaviors": [{"type": "open_url", "default_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[auto_session]}"}]
971
- if (session_groups and auto_session in session_groups) else
972
- [{"type": "callback", "value": {
973
- "action": "dir_new_group",
974
- "path": full_path,
975
- "session_name": auto_session
976
- }}]
977
- }]
972
+ "elements": [
973
+ {
974
+ "tag": "button",
975
+ "text": {"tag": "plain_text", "content": "Claude"},
976
+ "type": "primary",
977
+ "behaviors": [{"type": "callback", "value": {
978
+ "action": "dir_start",
979
+ "path": full_path,
980
+ "session_name": auto_session
981
+ }}]
982
+ },
983
+ group_btn
984
+ ]
978
985
  }
979
986
  ]
980
987
  })
988
+ elements.append({"tag": "hr"})
981
989
  else:
982
990
  elements.append({"tag": "markdown", "content": f"{indent}{icon} {name}"})
983
991
 
984
- if total > cap:
985
- elements.append({"tag": "markdown", "content": f"*...(共 {total} 项,仅显示前 {cap} 项)*"})
992
+ if not tree and total > PER_PAGE:
993
+ page_cols = []
994
+ if page > 0:
995
+ page_cols.append({
996
+ "tag": "column",
997
+ "width": "auto",
998
+ "elements": [{
999
+ "tag": "button",
1000
+ "text": {"tag": "plain_text", "content": "⬅️ 上一页"},
1001
+ "type": "default",
1002
+ "behaviors": [{"type": "callback", "value": {
1003
+ "action": "dir_page", "path": target_str, "page": page - 1
1004
+ }}]
1005
+ }]
1006
+ })
1007
+ page_cols.append({
1008
+ "tag": "column",
1009
+ "width": "weighted",
1010
+ "weight": 2,
1011
+ "vertical_align": "center",
1012
+ "elements": [{"tag": "markdown", "content": f"第 {page + 1}/{total_pages} 页"}]
1013
+ })
1014
+ if page < total_pages - 1:
1015
+ page_cols.append({
1016
+ "tag": "column",
1017
+ "width": "auto",
1018
+ "elements": [{
1019
+ "tag": "button",
1020
+ "text": {"tag": "plain_text", "content": "下一页 ➡️"},
1021
+ "type": "default",
1022
+ "behaviors": [{"type": "callback", "value": {
1023
+ "action": "dir_page", "path": target_str, "page": page + 1
1024
+ }}]
1025
+ }]
1026
+ })
1027
+ elements.append({"tag": "column_set", "flex_mode": "none", "columns": page_cols})
986
1028
 
987
1029
  elements.append({"tag": "hr"})
988
1030
  elements.append(_build_menu_button_only())
@@ -1090,17 +1132,6 @@ def build_menu_card(sessions: List[Dict], current_session: Optional[str] = None,
1090
1132
  "tag": "column_set",
1091
1133
  "flex_mode": "none",
1092
1134
  "columns": [
1093
- {
1094
- "tag": "column",
1095
- "width": "weighted",
1096
- "weight": 1,
1097
- "elements": [{
1098
- "tag": "button",
1099
- "text": {"tag": "plain_text", "content": "📖 帮助"},
1100
- "type": "default",
1101
- "behaviors": [{"type": "callback", "value": {"action": "menu_help"}}]
1102
- }]
1103
- },
1104
1135
  {
1105
1136
  "tag": "column",
1106
1137
  "width": "weighted",
@@ -22,6 +22,23 @@ from lark_oapi.api.cardkit.v1 import (
22
22
 
23
23
  from . import config
24
24
 
25
+
26
+ def _is_element_limit_error(msg: str) -> bool:
27
+ """判断飞书 API 返回的错误是否为元素超限"""
28
+ if not msg:
29
+ return False
30
+ lower = msg.lower()
31
+ return "element exceeds" in lower or "超限" in lower
32
+
33
+
34
+ class _ElementLimitResult:
35
+ """元素超限的哨兵返回值,__bool__ 为 False 兼容现有 if not success 逻辑"""
36
+ is_element_limit = True
37
+
38
+ def __bool__(self):
39
+ return False
40
+
41
+
25
42
  import sys as _sys
26
43
  _sys.path.insert(0, str(__import__('pathlib').Path(__file__).parent.parent))
27
44
  try:
@@ -193,6 +210,10 @@ class CardService:
193
210
  return True
194
211
  else:
195
212
  logger.warning(f"更新卡片失败(attempt={attempt+1}): card_id={card_id} seq={sequence} code={response.code} msg={response.msg}")
213
+ if _is_element_limit_error(response.msg):
214
+ # 元素超限是内容问题,重试无意义,直接返回哨兵值
215
+ logger.warning(f"检测到元素超限错误,跳过重试: card_id={card_id}")
216
+ return _ElementLimitResult()
196
217
  except Exception as e:
197
218
  logger.error(f"更新卡片异常(attempt={attempt+1}): card_id={card_id} seq={sequence} error={e}")
198
219
 
@@ -26,7 +26,6 @@ from .card_builder import (
26
26
  build_help_card,
27
27
  build_dir_card,
28
28
  build_menu_card,
29
- build_session_closed_card,
30
29
  )
31
30
  from .shared_memory_poller import SharedMemoryPoller, CardSlice
32
31
 
@@ -92,11 +91,17 @@ class LarkHandler:
92
91
 
93
92
  async def _attach(self, chat_id: str, session_name: str) -> bool:
94
93
  """统一 attach 逻辑(私聊/群聊共用)"""
94
+ # 在断开旧连接之前,先更新旧流式卡片为已断开状态
95
+ old_session = self._chat_sessions.get(chat_id)
96
+ old_slice = self._poller.stop_and_get_active_slice(chat_id)
97
+ if old_slice and old_session:
98
+ await self._update_card_disconnected(chat_id, old_session, old_slice)
99
+
95
100
  # 断开旧 bridge
96
101
  old = self._bridges.pop(chat_id, None)
97
102
  if old:
98
103
  await old.disconnect()
99
- self._poller.stop(chat_id)
104
+ # _poller.stop 已通过 stop_and_get_active_slice 完成
100
105
  self._chat_sessions.pop(chat_id, None)
101
106
  self._detached_slices.pop(chat_id, None)
102
107
 
@@ -132,19 +137,8 @@ class LarkHandler:
132
137
  self._detached_slices.pop(chat_id, None)
133
138
  self._remove_binding_by_chat(chat_id)
134
139
 
135
- card = build_session_closed_card(session_name)
136
140
  if active_slice:
137
- try:
138
- success = await card_service.update_card(
139
- card_id=active_slice.card_id,
140
- sequence=active_slice.sequence + 1,
141
- card_content=card,
142
- )
143
- if success:
144
- return
145
- except Exception as e:
146
- logger.warning(f"_on_disconnect 就地更新失败: {e}")
147
- await card_service.create_and_send_card(chat_id, card)
141
+ await self._update_card_disconnected(chat_id, session_name, active_slice)
148
142
 
149
143
  # ── 消息入口 ────────────────────────────────────────────────────────────
150
144
 
@@ -398,6 +392,9 @@ class LarkHandler:
398
392
  # 断开所有连接到此会话的 chat
399
393
  for cid, sname in list(self._chat_sessions.items()):
400
394
  if sname == session_name:
395
+ active_slice = self._poller.stop_and_get_active_slice(cid)
396
+ if active_slice:
397
+ await self._update_card_disconnected(cid, sname, active_slice)
401
398
  await self._detach(cid)
402
399
  self._remove_binding_by_chat(cid)
403
400
 
@@ -410,10 +407,44 @@ class LarkHandler:
410
407
  async def _handle_list_detach(self, user_id: str, chat_id: str,
411
408
  message_id: Optional[str] = None):
412
409
  """会话列表卡片中断开连接,就地刷新列表"""
410
+ session_name = self._chat_sessions.get(chat_id, "")
411
+ # 更新流式卡片为已断开状态
412
+ active_slice = self._poller.stop_and_get_active_slice(chat_id)
413
+ if active_slice and session_name:
414
+ await self._update_card_disconnected(chat_id, session_name, active_slice)
415
+
413
416
  self._remove_binding_by_chat(chat_id)
414
- await self._detach(chat_id)
417
+ await self._detach(chat_id) # bridge.disconnect + _poller.stop(幂等)
415
418
  await self._cmd_list(user_id, chat_id, message_id=message_id)
416
419
 
420
+ async def _update_card_disconnected(self, chat_id: str, session_name: str,
421
+ active_slice: 'CardSlice') -> bool:
422
+ """读取最新 blocks 并就地更新卡片为断开状态(disconnected=True)。Best-effort,不降级发新卡。"""
423
+ blocks = []
424
+ try:
425
+ import sys as _sys
426
+ _sys.path.insert(0, str(Path(__file__).parent.parent / "server"))
427
+ from shared_state import SharedStateReader, get_mq_path
428
+ mq_path = get_mq_path(session_name)
429
+ if mq_path.exists():
430
+ reader = SharedStateReader(session_name)
431
+ state = reader.read()
432
+ reader.close()
433
+ blocks = state.get("blocks", [])
434
+ except Exception:
435
+ pass
436
+ blocks_slice = blocks[active_slice.start_idx:]
437
+ card = build_stream_card(blocks_slice, disconnected=True, session_name=session_name)
438
+ try:
439
+ return await card_service.update_card(
440
+ card_id=active_slice.card_id,
441
+ sequence=active_slice.sequence + 1,
442
+ card_content=card,
443
+ )
444
+ except Exception as e:
445
+ logger.warning(f"_update_card_disconnected 失败 ({chat_id[:8]}...): {e}")
446
+ return False
447
+
417
448
  async def _handle_stream_detach(self, user_id: str, chat_id: str,
418
449
  session_name: str, message_id: Optional[str] = None):
419
450
  """流式卡片中断开连接,就地更新卡片为已断开状态"""
@@ -500,7 +531,7 @@ class LarkHandler:
500
531
  await self._send_or_update_card(chat_id, card, message_id)
501
532
 
502
533
  async def _cmd_ls(self, user_id: str, chat_id: str, args: str,
503
- tree: bool = False, message_id: Optional[str] = None):
534
+ tree: bool = False, message_id: Optional[str] = None, page: int = 0):
504
535
  """查看目录文件结构"""
505
536
  all_sessions = list_active_sessions()
506
537
  sessions_info = []
@@ -540,10 +571,11 @@ class LarkHandler:
540
571
  return
541
572
 
542
573
  session_groups = {sname: cid for cid, sname in self._chat_bindings.items() if cid.startswith("oc_")}
543
- card = build_dir_card(target, entries, sessions_info, tree=tree, session_groups=session_groups)
574
+ card = build_dir_card(target, entries, sessions_info, tree=tree, session_groups=session_groups, page=page)
544
575
  await self._send_or_update_card(chat_id, card, message_id)
545
576
 
546
- async def _cmd_new_group(self, user_id: str, chat_id: str, args: str):
577
+ async def _cmd_new_group(self, user_id: str, chat_id: str, args: str,
578
+ message_id: Optional[str] = None):
547
579
  """创建专属群聊并绑定 Claude 会话"""
548
580
  session_name = args.strip()
549
581
  if not session_name:
@@ -606,6 +638,8 @@ class LarkHandler:
606
638
  f"✅ 已创建专属群「【{dir_label}】{config.BOT_NAME}」并已连接\n"
607
639
  f"在群内直接发消息即可与 Claude 交互"
608
640
  )
641
+ # 刷新会话列表卡片,使"创建群聊"按钮变为"进入群聊"
642
+ await self._cmd_list(user_id, chat_id, message_id=message_id)
609
643
  except Exception as e:
610
644
  logger.error(f"创建群失败: {e}")
611
645
  await card_service.send_text(chat_id, f"创建群失败:{e}")
@@ -623,6 +657,7 @@ class LarkHandler:
623
657
 
624
658
  import json as _json
625
659
  import urllib.request
660
+ import urllib.error
626
661
  from . import config
627
662
  try:
628
663
  token_resp = urllib.request.urlopen(
@@ -635,20 +670,39 @@ class LarkHandler:
635
670
  )
636
671
  token = _json.loads(token_resp.read())["tenant_access_token"]
637
672
 
638
- disband_resp = urllib.request.urlopen(
639
- urllib.request.Request(
640
- f"https://open.feishu.cn/open-apis/im/v1/chats/{group_chat_id}",
641
- headers={"Authorization": f"Bearer {token}"},
642
- method="DELETE"
643
- ), timeout=10
644
- )
645
- disband_data = _json.loads(disband_resp.read())
646
- if disband_data.get("code") != 0:
647
- await card_service.send_text(chat_id, f"解散群失败:{disband_data.get('msg')}")
648
- return
649
-
673
+ feishu_ok = False
674
+ feishu_msg = ""
675
+ try:
676
+ disband_resp = urllib.request.urlopen(
677
+ urllib.request.Request(
678
+ f"https://open.feishu.cn/open-apis/im/v1/chats/{group_chat_id}",
679
+ headers={"Authorization": f"Bearer {token}"},
680
+ method="DELETE"
681
+ ), timeout=10
682
+ )
683
+ disband_data = _json.loads(disband_resp.read())
684
+ feishu_ok = disband_data.get("code") == 0
685
+ feishu_msg = disband_data.get("msg", "")
686
+ except urllib.error.HTTPError as e:
687
+ err_body = e.read().decode("utf-8", errors="replace")
688
+ try:
689
+ err_data = _json.loads(err_body)
690
+ feishu_ok = False
691
+ feishu_msg = f"code={err_data.get('code')} {err_data.get('msg', '')}"
692
+ except Exception:
693
+ feishu_ok = False
694
+ feishu_msg = f"HTTP {e.code}"
695
+ logger.error(f"解散群 API 失败: {feishu_msg}")
696
+
697
+ # 无论 Feishu delete 是否成功,都清理本地绑定
650
698
  self._remove_binding_by_chat(group_chat_id)
651
699
  await self._detach(group_chat_id)
700
+
701
+ if feishu_ok:
702
+ notice = "✅ 群聊已解散,绑定已解除"
703
+ else:
704
+ notice = f"⚠️ Feishu 群解散失败({feishu_msg}),已解除本地绑定。如需彻底解散请在飞书群内手动操作"
705
+ await card_service.send_text(chat_id, notice)
652
706
  await self._cmd_list(user_id, chat_id, message_id=message_id)
653
707
  except Exception as e:
654
708
  logger.error(f"解散群失败: {e}")
@@ -800,7 +854,7 @@ class LarkHandler:
800
854
  })
801
855
  except PermissionError:
802
856
  pass
803
- return entries[:30]
857
+ return entries
804
858
 
805
859
  @staticmethod
806
860
  def _collect_tree_entries(target, max_depth: int = 2, max_items: int = 60) -> list:
@@ -830,6 +884,15 @@ class LarkHandler:
830
884
  _walk(target, 0)
831
885
  return entries
832
886
 
887
+ async def disconnect_all_for_shutdown(self) -> None:
888
+ """lark stop 时清理所有活跃流式卡片(更新为已断开状态)"""
889
+ chat_ids = list(self._bridges.keys())
890
+ for chat_id in chat_ids:
891
+ session_name = self._chat_sessions.get(chat_id, "")
892
+ active_slice = self._poller.stop_and_get_active_slice(chat_id)
893
+ if active_slice and session_name:
894
+ await self._update_card_disconnected(chat_id, session_name, active_slice)
895
+
833
896
  @staticmethod
834
897
  def _get_pid_cwd(pid: int) -> Optional[str]:
835
898
  """获取进程的工作目录(macOS/Linux)"""
@@ -30,6 +30,17 @@ from lark_oapi.event.callback.model.p2_card_action_trigger import (
30
30
  from . import config
31
31
  from .lark_handler import handler
32
32
 
33
+
34
+ async def _graceful_shutdown() -> None:
35
+ """优雅关闭:更新所有活跃流式卡片为已断开状态后退出"""
36
+ try:
37
+ await handler.disconnect_all_for_shutdown()
38
+ except Exception as e:
39
+ print(f"[Lark] graceful shutdown 异常: {e}")
40
+ finally:
41
+ import os
42
+ os._exit(0)
43
+
33
44
  def check_user_allowed(user_id: str) -> bool:
34
45
  """检查用户是否在白名单中"""
35
46
  if not config.ENABLE_USER_WHITELIST:
@@ -148,7 +159,7 @@ def handle_card_action(event: P2CardActionTrigger) -> P2CardActionTriggerRespons
148
159
  if action_type == "list_new_group":
149
160
  session_name = action_value.get("session", "")
150
161
  print(f"[Lark] list_new_group: session={session_name}")
151
- asyncio.create_task(handler._cmd_new_group(user_id, chat_id, session_name))
162
+ asyncio.create_task(handler._cmd_new_group(user_id, chat_id, session_name, message_id=message_id))
152
163
  return None
153
164
 
154
165
  # 列表卡片:解散群聊
@@ -165,6 +176,14 @@ def handle_card_action(event: P2CardActionTrigger) -> P2CardActionTriggerRespons
165
176
  asyncio.create_task(handler._cmd_ls(user_id, chat_id, path, message_id=message_id))
166
177
  return None
167
178
 
179
+ # 目录卡片:翻页
180
+ if action_type == "dir_page":
181
+ path = action_value.get("path", "")
182
+ page = int(action_value.get("page", 0))
183
+ print(f"[Lark] dir_page: path={path}, page={page}")
184
+ asyncio.create_task(handler._cmd_ls(user_id, chat_id, path, message_id=message_id, page=page))
185
+ return None
186
+
168
187
  # 目录卡片:在该目录创建新 Claude 会话
169
188
  if action_type == "dir_start":
170
189
  path = action_value.get("path", "")
@@ -219,8 +238,13 @@ def handle_card_action(event: P2CardActionTrigger) -> P2CardActionTriggerRespons
219
238
  # 快捷键按钮(callback 模式)
220
239
  if action_type == "send_key":
221
240
  key_name = action_value.get("key", "")
222
- print(f"[Lark] send_key: key={key_name}")
223
- asyncio.create_task(handler.send_raw_key(user_id, chat_id, key_name))
241
+ times = action_value.get("times", 1)
242
+ print(f"[Lark] send_key: key={key_name}" + (f" ×{times}" if times > 1 else ""))
243
+ async def _multi_send(k=key_name, t=times):
244
+ for _ in range(t):
245
+ await handler.send_raw_key(user_id, chat_id, k)
246
+ await asyncio.sleep(0.15)
247
+ asyncio.create_task(_multi_send())
224
248
  return None
225
249
 
226
250
  # 各卡片底部菜单按钮:辅助卡片就地→菜单,流式卡片降级新卡
@@ -288,12 +312,20 @@ class LarkBot:
288
312
  self.ws_client.start()
289
313
 
290
314
  def _signal_handler(self, signum, frame):
291
- """处理退出信号"""
315
+ """处理退出信号(SIGTERM / SIGINT)"""
292
316
  print("\n正在关闭...")
293
317
  self.running = False
294
- if self.ws_client:
295
- # WebSocket 客户端没有 stop 方法,直接退出
296
- sys.exit(0)
318
+ # 调度异步清理(更新所有活跃卡片为已断开状态后退出)
319
+ try:
320
+ loop = asyncio.get_event_loop()
321
+ if loop.is_running():
322
+ loop.call_soon_threadsafe(
323
+ lambda: asyncio.ensure_future(_graceful_shutdown())
324
+ )
325
+ return
326
+ except Exception:
327
+ pass
328
+ sys.exit(0)
297
329
 
298
330
 
299
331
  def main():
@@ -247,7 +247,13 @@ class SharedMemoryPoller:
247
247
  card_content=card_dict,
248
248
  )
249
249
 
250
- if not success:
250
+ if getattr(success, 'is_element_limit', False):
251
+ # 元素超限:冻结旧卡 + 推新流式卡
252
+ await self._handle_element_limit(
253
+ tracker, blocks, status_line, bottom_bar, agent_panel, option_block
254
+ )
255
+ return
256
+ elif not success:
251
257
  # 降级:创建新卡片替代
252
258
  logger.warning(
253
259
  f"update_card 失败 card_id={active.card_id} seq={active.sequence},降级为新卡片"
@@ -305,6 +311,48 @@ class SharedMemoryPoller:
305
311
  else:
306
312
  logger.warning(f"create_card 失败 session={tracker.session_name}")
307
313
 
314
+ async def _handle_element_limit(
315
+ self, tracker: StreamTracker, blocks: List[dict],
316
+ status_line: Optional[dict], bottom_bar: Optional[dict],
317
+ agent_panel: Optional[dict] = None,
318
+ option_block: Optional[dict] = None,
319
+ ) -> None:
320
+ """元素超限:冻结旧卡片 + 推送新流式卡片"""
321
+ active = tracker.cards[-1]
322
+ logger.warning(f"元素超限,冻结卡片 {active.card_id} 并推新卡")
323
+
324
+ # 1. 冻结旧卡片(灰色 header,无状态区和按钮)
325
+ from .card_builder import build_stream_card
326
+ blocks_slice = blocks[active.start_idx:]
327
+ frozen_card = build_stream_card(blocks_slice, None, None, is_frozen=True)
328
+ active.sequence += 1
329
+ await self._card_service.update_card(active.card_id, active.sequence, frozen_card)
330
+ active.frozen = True
331
+ _track_stats('card', 'freeze', session_name=tracker.session_name,
332
+ chat_id=tracker.chat_id)
333
+
334
+ # 2. 创建新流式卡片,从最近 INITIAL_WINDOW 个 blocks 开始(重置窗口)
335
+ new_start = max(0, len(blocks) - INITIAL_WINDOW)
336
+ new_blocks = blocks[new_start:]
337
+ if not new_blocks and not status_line and not bottom_bar:
338
+ return
339
+ new_card_dict = build_stream_card(
340
+ new_blocks, status_line, bottom_bar,
341
+ agent_panel=agent_panel, option_block=option_block,
342
+ session_name=tracker.session_name,
343
+ )
344
+ new_card_id = await self._card_service.create_card(new_card_dict)
345
+ if new_card_id:
346
+ await self._card_service.send_card(tracker.chat_id, new_card_id)
347
+ tracker.cards.append(CardSlice(card_id=new_card_id, start_idx=new_start))
348
+ tracker.content_hash = self._compute_hash(new_blocks, status_line, bottom_bar, agent_panel, option_block)
349
+ _track_stats('card', 'create', session_name=tracker.session_name,
350
+ chat_id=tracker.chat_id)
351
+ logger.info(
352
+ f"[ELEMENT_LIMIT_SPLIT] session={tracker.session_name} "
353
+ f"new_start={new_start} blocks={len(new_blocks)} card_id={new_card_id}"
354
+ )
355
+
308
356
  async def _freeze_and_split(
309
357
  self, tracker: StreamTracker, blocks: List[dict],
310
358
  status_line: Optional[dict], bottom_bar: Optional[dict],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "remote-claude",
3
- "version": "0.1.6",
3
+ "version": "0.2.0",
4
4
  "description": "双端共享 Claude CLI 工具",
5
5
  "bin": {
6
6
  "remote-claude": "bin/remote-claude",
package/server/server.py CHANGED
@@ -624,22 +624,22 @@ class ProxyServer:
624
624
  raise
625
625
 
626
626
  if pid == 0:
627
- # 子进程:恢复调用方 shell 的完整环境变量
628
- for k, v in _extra_env.items():
629
- os.environ[k] = v
630
- # 环境已加载到内存,立即删除快照文件(execvp 前销毁)
627
+ # 环境已加载到内存,立即删除快照文件(exec 前销毁)
631
628
  try:
632
629
  env_snapshot_path.unlink()
633
630
  except Exception:
634
631
  pass
632
+ # 以快照为权威来源完整替换子进程环境,确保 unset 的变量也消失
633
+ # 若 snapshot 加载失败(_extra_env 为空),降级使用当前进程环境
634
+ child_env = dict(_extra_env) if _extra_env else dict(os.environ)
635
635
  # 恢复 TERM 以支持 kitty keyboard protocol(Shift+Enter 等扩展键)
636
636
  # tmux 会将 TERM 改为 tmux-256color,导致 Claude CLI 不启用 kitty protocol
637
- os.environ['TERM'] = 'xterm-256color'
637
+ child_env['TERM'] = 'xterm-256color'
638
638
  # 清除 tmux 标识变量(PTY 数据不经过 tmux,不应让 Claude CLI 误判终端环境)
639
- for key in ('TMUX', 'TMUX_PANE'):
640
- os.environ.pop(key, None)
641
- os.execvp("claude", ["claude"] + self.claude_args)
642
- os._exit(1) # execvp 失败时兜底退出
639
+ child_env.pop('TMUX', None)
640
+ child_env.pop('TMUX_PANE', None)
641
+ os.execvpe("claude", ["claude"] + self.claude_args, child_env)
642
+ os._exit(1) # execvpe 失败时兜底退出
643
643
  else:
644
644
  # 父进程
645
645
  self.master_fd = fd