remote-claude 0.1.6 → 0.1.7

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.
@@ -773,58 +773,57 @@ def _build_session_list_elements(sessions: List[Dict], current_session: Optional
773
773
  btn_label = "进入会话"
774
774
  btn_type = "primary"
775
775
  btn_action = "list_attach"
776
- columns = [
777
- {
778
- "tag": "column",
779
- "width": "weighted",
780
- "weight": 5,
781
- "elements": [{"tag": "markdown", "content": header_text}]
782
- },
776
+ has_group = bool(session_groups and name in session_groups)
777
+
778
+ # 右列按钮(纵向堆叠)
779
+ right_buttons = [
783
780
  {
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
- }]
781
+ "tag": "button",
782
+ "text": {"tag": "plain_text", "content": btn_label},
783
+ "type": btn_type,
784
+ "behaviors": [{"type": "callback", "value": {
785
+ "action": btn_action, "session": name
786
+ }}]
795
787
  },
796
788
  {
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
- }]
789
+ "tag": "button",
790
+ "text": {"tag": "plain_text", "content": "进入群聊" if has_group else "创建群聊"},
791
+ "type": "default",
792
+ "behaviors": [{"type": "open_url",
793
+ "default_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[name]}",
794
+ "android_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[name]}",
795
+ "ios_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[name]}",
796
+ "pc_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[name]}"}]
797
+ if has_group else
798
+ [{"type": "callback", "value": {"action": "list_new_group", "session": name}}]
808
799
  },
809
800
  ]
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
- }]
801
+ if has_group:
802
+ right_buttons.append({
803
+ "tag": "button",
804
+ "text": {"tag": "plain_text", "content": "解散群聊"},
805
+ "type": "danger",
806
+ "behaviors": [{"type": "callback", "value": {
807
+ "action": "list_disband_group", "session": name
808
+ }}]
823
809
  })
824
810
  elements.append({
825
811
  "tag": "column_set",
826
812
  "flex_mode": "none",
827
- "columns": columns
813
+ "columns": [
814
+ {
815
+ "tag": "column",
816
+ "width": "weighted",
817
+ "weight": 3,
818
+ "elements": [{"tag": "markdown", "content": header_text}]
819
+ },
820
+ {
821
+ "tag": "column",
822
+ "width": "weighted",
823
+ "weight": 2,
824
+ "elements": right_buttons
825
+ },
826
+ ]
828
827
  })
829
828
  elements.append({"tag": "hr"})
830
829
 
@@ -967,7 +966,11 @@ def build_dir_card(target, entries: List[Dict], sessions: List[Dict], tree: bool
967
966
  "tag": "button",
968
967
  "text": {"tag": "plain_text", "content": "进入群聊" if (session_groups and auto_session in session_groups) else "创建群聊"},
969
968
  "type": "default",
970
- "behaviors": [{"type": "open_url", "default_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[auto_session]}"}]
969
+ "behaviors": [{"type": "open_url",
970
+ "default_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[auto_session]}",
971
+ "android_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[auto_session]}",
972
+ "ios_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[auto_session]}",
973
+ "pc_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[auto_session]}"}]
971
974
  if (session_groups and auto_session in session_groups) else
972
975
  [{"type": "callback", "value": {
973
976
  "action": "dir_new_group",
@@ -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
  """流式卡片中断开连接,就地更新卡片为已断开状态"""
@@ -543,7 +574,8 @@ class LarkHandler:
543
574
  card = build_dir_card(target, entries, sessions_info, tree=tree, session_groups=session_groups)
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}")
@@ -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
  # 列表卡片:解散群聊
@@ -288,12 +299,20 @@ class LarkBot:
288
299
  self.ws_client.start()
289
300
 
290
301
  def _signal_handler(self, signum, frame):
291
- """处理退出信号"""
302
+ """处理退出信号(SIGTERM / SIGINT)"""
292
303
  print("\n正在关闭...")
293
304
  self.running = False
294
- if self.ws_client:
295
- # WebSocket 客户端没有 stop 方法,直接退出
296
- sys.exit(0)
305
+ # 调度异步清理(更新所有活跃卡片为已断开状态后退出)
306
+ try:
307
+ loop = asyncio.get_event_loop()
308
+ if loop.is_running():
309
+ loop.call_soon_threadsafe(
310
+ lambda: asyncio.ensure_future(_graceful_shutdown())
311
+ )
312
+ return
313
+ except Exception:
314
+ pass
315
+ sys.exit(0)
297
316
 
298
317
 
299
318
  def main():
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "remote-claude",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
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