remote-claude 0.2.7 → 0.2.8

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/.env.example CHANGED
@@ -18,3 +18,7 @@ ALLOWED_USERS=ou_xxxxx,ou_yyyyy
18
18
  # 支持多词命令,如:ccr code、/usr/local/bin/claude
19
19
  # CLAUDE_COMMAND=claude
20
20
 
21
+ # 流式卡片配置(可选)
22
+ # 单张卡片最多显示的 block 数量,超限自动冻结并创建新卡片(默认 50)
23
+ # MAX_CARD_BLOCKS=50
24
+
package/init.sh CHANGED
@@ -1,7 +1,5 @@
1
1
  #!/bin/bash
2
2
 
3
- set -e
4
-
5
3
  # 颜色定义
6
4
  RED=$'\033[0;31m'
7
5
  GREEN=$'\033[0;32m'
@@ -128,7 +126,7 @@ check_uv() {
128
126
  # 方式五:brew(macOS 备用)
129
127
  if ! command -v uv &> /dev/null && [[ "$OS" == "Darwin" ]] && command -v brew &> /dev/null; then
130
128
  print_warning "尝试 brew install uv..."
131
- brew install uv
129
+ brew install uv 2>/dev/null || true
132
130
  fi
133
131
 
134
132
  if command -v uv &> /dev/null; then
@@ -181,18 +179,18 @@ check_tmux() {
181
179
  fi
182
180
  print_success "Homebrew 安装成功"
183
181
  fi
184
- brew install tmux
182
+ brew install tmux 2>/dev/null || true
185
183
  elif [[ "$OS" == "Linux" ]]; then
186
184
  if command -v apt-get &> /dev/null; then
187
- sudo apt-get update && sudo apt-get install -y tmux
185
+ sudo apt-get update && sudo apt-get install -y tmux || true
188
186
  elif command -v yum &> /dev/null; then
189
- sudo yum install -y tmux
187
+ sudo yum install -y tmux || true
190
188
  elif command -v pacman &> /dev/null; then
191
- sudo pacman -Sy --noconfirm tmux
189
+ sudo pacman -Sy --noconfirm tmux || true
192
190
  elif command -v apk &> /dev/null; then
193
- sudo apk add --no-cache tmux
191
+ sudo apk add --no-cache tmux || true
194
192
  elif command -v zypper &> /dev/null; then
195
- sudo zypper install -y tmux
193
+ sudo zypper install -y tmux || true
196
194
  else
197
195
  print_warning "无法识别包管理器,尝试从源码编译 tmux..."
198
196
  install_tmux_from_source
@@ -212,10 +210,10 @@ check_tmux() {
212
210
  if [[ "$OS" == "Darwin" ]]; then
213
211
  brew install libevent ncurses pkg-config bison 2>/dev/null || true
214
212
  elif command -v apt-get &> /dev/null; then
215
- sudo apt-get install -y build-essential libevent-dev libncurses5-dev libncursesw5-dev bison pkg-config
213
+ sudo apt-get install -y build-essential libevent-dev libncurses5-dev libncursesw5-dev bison pkg-config || true
216
214
  elif command -v yum &> /dev/null; then
217
- sudo yum groupinstall -y "Development Tools"
218
- sudo yum install -y libevent-devel ncurses-devel bison
215
+ sudo yum groupinstall -y "Development Tools" || true
216
+ sudo yum install -y libevent-devel ncurses-devel bison || true
219
217
  fi
220
218
 
221
219
  # 确定安装前缀
@@ -249,9 +247,9 @@ check_tmux() {
249
247
  fi
250
248
 
251
249
  if [[ "$PREFIX" == "/usr/local" ]]; then
252
- sudo make -C "$SRC_DIR" install
250
+ sudo make -C "$SRC_DIR" install || { WARNINGS+=("tmux make install 失败,请手动安装 tmux ${REQUIRED_MAJOR}.${REQUIRED_MINOR}+"); return; }
253
251
  else
254
- make -C "$SRC_DIR" install
252
+ make -C "$SRC_DIR" install || { WARNINGS+=("tmux make install 失败,请手动安装 tmux ${REQUIRED_MAJOR}.${REQUIRED_MINOR}+"); return; }
255
253
  # 若 $HOME/.local/bin 不在 PATH 中,自动写入 shell 配置
256
254
  if [[ ":$PATH:" != *":$HOME/.local/bin:"* ]]; then
257
255
  export PATH="$HOME/.local/bin:$PATH"
@@ -363,9 +361,9 @@ install_dependencies() {
363
361
 
364
362
  print_info "正在通过 uv 同步依赖..."
365
363
  if $NPM_MODE; then
366
- uv sync --frozen
364
+ uv sync || { print_error "依赖安装失败"; exit 1; }
367
365
  else
368
- uv sync
366
+ uv sync || { print_error "依赖安装失败"; exit 1; }
369
367
  fi
370
368
 
371
369
  print_success "依赖安装完成"
@@ -451,7 +449,7 @@ configure_shell() {
451
449
  print_header "安装快捷命令"
452
450
 
453
451
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
454
- chmod +x "$SCRIPT_DIR/bin/cla" "$SCRIPT_DIR/bin/cl" "$SCRIPT_DIR/bin/remote-claude"
452
+ chmod +x "$SCRIPT_DIR/bin/cla" "$SCRIPT_DIR/bin/cl" "$SCRIPT_DIR/bin/remote-claude" 2>/dev/null || true
455
453
 
456
454
  # 优先 /usr/local/bin,权限不够则选 ~/bin 或 ~/.local/bin 中已在 PATH 里的
457
455
  BIN_DIR="/usr/local/bin"
@@ -479,12 +477,12 @@ configure_shell() {
479
477
  fi
480
478
  fi
481
479
  mkdir -p "$BIN_DIR"
482
- ln -sf "$SCRIPT_DIR/bin/cla" "$BIN_DIR/cla"
483
- ln -sf "$SCRIPT_DIR/bin/cl" "$BIN_DIR/cl"
484
- ln -sf "$SCRIPT_DIR/bin/remote-claude" "$BIN_DIR/remote-claude"
480
+ ln -sf "$SCRIPT_DIR/bin/cla" "$BIN_DIR/cla" 2>/dev/null || true
481
+ ln -sf "$SCRIPT_DIR/bin/cl" "$BIN_DIR/cl" 2>/dev/null || true
482
+ ln -sf "$SCRIPT_DIR/bin/remote-claude" "$BIN_DIR/remote-claude" 2>/dev/null || true
485
483
  else
486
- ln -sf "$SCRIPT_DIR/bin/cl" "$BIN_DIR/cl"
487
- ln -sf "$SCRIPT_DIR/bin/remote-claude" "$BIN_DIR/remote-claude"
484
+ ln -sf "$SCRIPT_DIR/bin/cl" "$BIN_DIR/cl" 2>/dev/null || true
485
+ ln -sf "$SCRIPT_DIR/bin/remote-claude" "$BIN_DIR/remote-claude" 2>/dev/null || true
488
486
  fi
489
487
 
490
488
  print_success "已安装 cla、cl 和 remote-claude 到 $BIN_DIR"
@@ -525,7 +523,7 @@ restart_lark_client() {
525
523
  SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
526
524
  print_info "正在重启飞书客户端..."
527
525
  cd "$SCRIPT_DIR"
528
- uv run python3 remote_claude.py lark restart
526
+ uv run python3 remote_claude.py lark restart || { WARNINGS+=("飞书客户端重启失败,请手动运行: uv run python3 remote_claude.py lark restart"); return; }
529
527
  print_success "飞书客户端已重启"
530
528
  }
531
529
 
@@ -17,6 +17,12 @@ from typing import Dict, Any, List, Optional
17
17
 
18
18
  _cb_logger = logging.getLogger('CardBuilder')
19
19
 
20
+ # CLI 类型 → 显示名称映射(用于卡片标题中的"就绪"文案)
21
+ CLI_NAMES: Dict[str, str] = {
22
+ "claude": "Claude",
23
+ "codex": "Codex",
24
+ }
25
+
20
26
  # 版本号:从 package.json 读取,import 时只读一次
21
27
  try:
22
28
  _pkg = _pl.Path(__file__).parent.parent / "package.json"
@@ -539,6 +545,17 @@ def _render_block_colored(block_dict: dict) -> Optional[str]:
539
545
  parts.append("🔐 权限确认")
540
546
  return "\n".join(parts)
541
547
 
548
+ elif typ == "SystemBlock":
549
+ content = block_dict.get("content", "")
550
+ if not content:
551
+ return None
552
+ ansi_content = block_dict.get("ansi_content", "")
553
+ ansi_ind = block_dict.get("ansi_indicator", "")
554
+ indicator = block_dict.get("indicator", "✻")
555
+ ind_md = _ansi_to_lark_md(ansi_ind) if ansi_ind else _escape_md(indicator)
556
+ content_md = _ansi_to_lark_md(ansi_content) if ansi_content else _escape_md(content)
557
+ return f"{ind_md} {content_md}"
558
+
542
559
  return None
543
560
 
544
561
 
@@ -565,6 +582,7 @@ def _determine_header(
565
582
  is_frozen: bool,
566
583
  option_block: Optional[dict] = None,
567
584
  disconnected: bool = False,
585
+ cli_type: str = "claude",
568
586
  ) -> tuple:
569
587
  """确定卡片标题和颜色模板,返回 (title, template)"""
570
588
  if disconnected:
@@ -601,7 +619,8 @@ def _determine_header(
601
619
  return "🔐 等待权限确认", "red"
602
620
  return "🤔 等待选择", "blue"
603
621
 
604
- return "✅ Claude 就绪", "green"
622
+ cli_name = CLI_NAMES.get(cli_type, "Claude")
623
+ return f"✅ {cli_name} 就绪", "green"
605
624
 
606
625
 
607
626
  def _extract_buttons(blocks: List[dict], option_block: Optional[dict] = None) -> List[Dict[str, str]]:
@@ -626,6 +645,7 @@ def build_stream_card(
626
645
  option_block: Optional[dict] = None,
627
646
  session_name: Optional[str] = None,
628
647
  disconnected: bool = False,
648
+ cli_type: str = "claude",
629
649
  ) -> Dict[str, Any]:
630
650
  """从共享内存 blocks 流构建飞书卡片
631
651
 
@@ -637,7 +657,8 @@ def build_stream_card(
637
657
  """
638
658
  title, template = _determine_header(
639
659
  blocks, status_line, bottom_bar, is_frozen,
640
- option_block=option_block, disconnected=disconnected
660
+ option_block=option_block, disconnected=disconnected,
661
+ cli_type=cli_type,
641
662
  )
642
663
 
643
664
  # === 第一层:内容区 ===
@@ -749,12 +770,17 @@ def build_stream_card(
749
770
 
750
771
  # === 辅助卡片(保留不变)===
751
772
 
752
- def _build_session_list_elements(sessions: List[Dict], current_session: Optional[str], session_groups: Optional[Dict[str, str]]) -> List[Dict]:
773
+ def _build_session_list_elements(sessions: List[Dict], current_session: Optional[str], session_groups: Optional[Dict[str, str]], page: int = 0) -> List[Dict]:
753
774
  """构建会话列表元素(供 build_menu_card 复用)"""
754
775
  import os
755
776
  elements = []
756
777
  if sessions:
757
- for s in sessions:
778
+ PER_PAGE = 8
779
+ total = len(sessions)
780
+ total_pages = max(1, (total + PER_PAGE - 1) // PER_PAGE)
781
+ page = max(0, min(page, total_pages - 1))
782
+ shown = sessions[page * PER_PAGE : (page + 1) * PER_PAGE]
783
+ for s in shown:
758
784
  name = s["name"]
759
785
  cwd = s.get("cwd", "")
760
786
  start_time = s.get("start_time", "")
@@ -846,6 +872,41 @@ def _build_session_list_elements(sessions: List[Dict], current_session: Optional
846
872
 
847
873
  if elements and elements[-1].get("tag") == "hr":
848
874
  elements.pop()
875
+
876
+ if total > PER_PAGE:
877
+ prev_disabled = page == 0
878
+ next_disabled = page >= total_pages - 1
879
+ prev_btn = {
880
+ "tag": "button",
881
+ "text": {"tag": "plain_text", "content": "⬅ 上一页"},
882
+ "type": "default",
883
+ **({"disabled": True} if prev_disabled else {"behaviors": [{"type": "callback", "value": {
884
+ "action": "menu_page", "page": page - 1
885
+ }}]})
886
+ }
887
+ next_btn = {
888
+ "tag": "button",
889
+ "text": {"tag": "plain_text", "content": "下一页 ➡"},
890
+ "type": "default",
891
+ **({"disabled": True} if next_disabled else {"behaviors": [{"type": "callback", "value": {
892
+ "action": "menu_page", "page": page + 1
893
+ }}]})
894
+ }
895
+ elements.append({"tag": "hr"})
896
+ elements.append({
897
+ "tag": "column_set",
898
+ "flex_mode": "none",
899
+ "horizontal_spacing": "small",
900
+ "columns": [
901
+ {"tag": "column", "width": "weighted", "weight": 1, "elements": [{"tag": "markdown", "content": " "}]},
902
+ {"tag": "column", "width": "auto", "elements": [prev_btn]},
903
+ {"tag": "column", "width": "auto", "vertical_align": "center", "elements": [
904
+ {"tag": "markdown", "content": f"第 {page + 1}/{total_pages} 页"}
905
+ ]},
906
+ {"tag": "column", "width": "auto", "elements": [next_btn]},
907
+ {"tag": "column", "width": "weighted", "weight": 1, "elements": [{"tag": "markdown", "content": " "}]},
908
+ ]
909
+ })
849
910
  else:
850
911
  elements.append({
851
912
  "tag": "markdown",
@@ -1001,41 +1062,38 @@ def build_dir_card(target, entries: List[Dict], sessions: List[Dict], tree: bool
1001
1062
  elements.append({"tag": "markdown", "content": f"{indent}{icon} {name}"})
1002
1063
 
1003
1064
  if not tree and total > PER_PAGE:
1004
- page_cols = []
1005
- if page > 0:
1006
- page_cols.append({
1007
- "tag": "column",
1008
- "width": "auto",
1009
- "elements": [{
1010
- "tag": "button",
1011
- "text": {"tag": "plain_text", "content": "⬅️ 上一页"},
1012
- "type": "default",
1013
- "behaviors": [{"type": "callback", "value": {
1014
- "action": "dir_page", "path": target_str, "page": page - 1
1015
- }}]
1016
- }]
1017
- })
1018
- page_cols.append({
1019
- "tag": "column",
1020
- "width": "weighted",
1021
- "weight": 2,
1022
- "vertical_align": "center",
1023
- "elements": [{"tag": "markdown", "content": f"第 {page + 1}/{total_pages} 页"}]
1065
+ prev_disabled = page == 0
1066
+ next_disabled = page >= total_pages - 1
1067
+ prev_btn = {
1068
+ "tag": "button",
1069
+ "text": {"tag": "plain_text", "content": "⬅ 上一页"},
1070
+ "type": "default",
1071
+ **({"disabled": True} if prev_disabled else {"behaviors": [{"type": "callback", "value": {
1072
+ "action": "dir_page", "path": target_str, "page": page - 1
1073
+ }}]})
1074
+ }
1075
+ next_btn = {
1076
+ "tag": "button",
1077
+ "text": {"tag": "plain_text", "content": "下一页 ➡"},
1078
+ "type": "default",
1079
+ **({"disabled": True} if next_disabled else {"behaviors": [{"type": "callback", "value": {
1080
+ "action": "dir_page", "path": target_str, "page": page + 1
1081
+ }}]})
1082
+ }
1083
+ elements.append({
1084
+ "tag": "column_set",
1085
+ "flex_mode": "none",
1086
+ "horizontal_spacing": "small",
1087
+ "columns": [
1088
+ {"tag": "column", "width": "weighted", "weight": 1, "elements": [{"tag": "markdown", "content": " "}]},
1089
+ {"tag": "column", "width": "auto", "elements": [prev_btn]},
1090
+ {"tag": "column", "width": "auto", "vertical_align": "center", "elements": [
1091
+ {"tag": "markdown", "content": f"第 {page + 1}/{total_pages} 页"}
1092
+ ]},
1093
+ {"tag": "column", "width": "auto", "elements": [next_btn]},
1094
+ {"tag": "column", "width": "weighted", "weight": 1, "elements": [{"tag": "markdown", "content": " "}]},
1095
+ ]
1024
1096
  })
1025
- if page < total_pages - 1:
1026
- page_cols.append({
1027
- "tag": "column",
1028
- "width": "auto",
1029
- "elements": [{
1030
- "tag": "button",
1031
- "text": {"tag": "plain_text", "content": "下一页 ➡️"},
1032
- "type": "default",
1033
- "behaviors": [{"type": "callback", "value": {
1034
- "action": "dir_page", "path": target_str, "page": page + 1
1035
- }}]
1036
- }]
1037
- })
1038
- elements.append({"tag": "column_set", "flex_mode": "none", "columns": page_cols})
1039
1097
 
1040
1098
  elements.append({"tag": "hr"})
1041
1099
  elements.append(_build_menu_button_only())
@@ -1124,12 +1182,13 @@ def build_session_closed_card(session_name: str) -> Dict[str, Any]:
1124
1182
 
1125
1183
 
1126
1184
  def build_menu_card(sessions: List[Dict], current_session: Optional[str] = None,
1127
- session_groups: Optional[Dict[str, str]] = None) -> Dict[str, Any]:
1185
+ session_groups: Optional[Dict[str, str]] = None, page: int = 0) -> Dict[str, Any]:
1128
1186
  """构建快捷操作菜单卡片(/menu 和 /list 共用):内嵌会话列表 + 快捷操作"""
1129
1187
  elements = []
1130
1188
 
1131
1189
  elements.append({"tag": "markdown", "content": "**会话管理**"})
1132
- elements.extend(_build_session_list_elements(sessions, current_session, session_groups))
1190
+ elements.append({"tag": "hr"})
1191
+ elements.extend(_build_session_list_elements(sessions, current_session, session_groups, page=page))
1133
1192
 
1134
1193
  elements.append({"tag": "hr"})
1135
1194
  elements.append({"tag": "markdown", "content": "**快捷操作**"})
@@ -1159,6 +1218,17 @@ def build_menu_card(sessions: List[Dict], current_session: Optional[str] = None,
1159
1218
  "behaviors": [{"type": "callback", "value": {"action": "menu_tree"}}]
1160
1219
  }]
1161
1220
  },
1221
+ {
1222
+ "tag": "column",
1223
+ "width": "weighted",
1224
+ "weight": 1,
1225
+ "elements": [{
1226
+ "tag": "button",
1227
+ "text": {"tag": "plain_text", "content": "🔄 刷新"},
1228
+ "type": "default",
1229
+ "behaviors": [{"type": "callback", "value": {"action": "menu_open"}}]
1230
+ }]
1231
+ },
1162
1232
  ]
1163
1233
  })
1164
1234
 
@@ -35,3 +35,6 @@ BOT_NAME = os.getenv("BOT_NAME", "Claude")
35
35
 
36
36
  # 群聊名称前缀(格式:{GROUP_NAME_PREFIX}{dir}-{HH-MM})
37
37
  GROUP_NAME_PREFIX = os.getenv("GROUP_NAME_PREFIX", "【Remote-Claude】")
38
+
39
+ # 流式卡片配置
40
+ MAX_CARD_BLOCKS = int(os.getenv("MAX_CARD_BLOCKS", "50"))
@@ -103,7 +103,13 @@ class LarkHandler:
103
103
  except Exception as e:
104
104
  logger.warning(f"保存群聊 ID 失败: {e}")
105
105
 
106
- def _remove_binding_by_chat(self, chat_id: str):
106
+ def _remove_binding_by_chat(self, chat_id: str, force: bool = False):
107
+ """移除 chat_id 的绑定。
108
+ 群聊绑定默认不移除(避免断开后无法解散群);
109
+ force=True 时强制移除(用于会话终止/解散群场景)。
110
+ """
111
+ if not force and chat_id in self._group_chat_ids:
112
+ return
107
113
  self._chat_bindings.pop(chat_id, None)
108
114
  self._save_chat_bindings()
109
115
 
@@ -416,7 +422,14 @@ class LarkHandler:
416
422
  if active_slice:
417
423
  await self._update_card_disconnected(cid, sname, active_slice)
418
424
  await self._detach(cid)
419
- self._remove_binding_by_chat(cid)
425
+ self._remove_binding_by_chat(cid, force=True)
426
+
427
+ # 清理所有残留绑定(包括已断开的群聊,其绑定在断开时被保留)
428
+ for cid in [c for c, s in list(self._chat_bindings.items()) if s == session_name]:
429
+ self._group_chat_ids.discard(cid)
430
+ self._chat_bindings.pop(cid, None)
431
+ self._save_chat_bindings()
432
+ self._save_group_chat_ids()
420
433
 
421
434
  if tmux_session_exists(session_name):
422
435
  tmux_kill_session(session_name)
@@ -542,7 +555,7 @@ class LarkHandler:
542
555
  await self._send_or_update_card(chat_id, card, message_id)
543
556
 
544
557
  async def _cmd_menu(self, user_id: str, chat_id: str,
545
- message_id: Optional[str] = None):
558
+ message_id: Optional[str] = None, page: int = 0):
546
559
  """显示快捷操作菜单(内嵌会话列表)"""
547
560
  sessions = list_active_sessions()
548
561
  current = self._chat_sessions.get(chat_id)
@@ -551,7 +564,7 @@ class LarkHandler:
551
564
  for cid in self._group_chat_ids
552
565
  if cid in self._chat_bindings
553
566
  }
554
- card = build_menu_card(sessions, current_session=current, session_groups=session_groups)
567
+ card = build_menu_card(sessions, current_session=current, session_groups=session_groups, page=page)
555
568
  await self._send_or_update_card(chat_id, card, message_id)
556
569
 
557
570
  async def _cmd_ls(self, user_id: str, chat_id: str, args: str,
@@ -665,11 +678,6 @@ class LarkHandler:
665
678
  # 立即 attach,让新群即刻开始接收 Claude 输出
666
679
  await self._attach(group_chat_id, session_name)
667
680
 
668
- await card_service.send_text(
669
- chat_id,
670
- f"✅ 已创建专属群「{group_name}」并已连接\n"
671
- f"在群内直接发消息即可与 Claude 交互"
672
- )
673
681
  # 刷新会话列表卡片,使"创建群聊"按钮变为"进入群聊"
674
682
  await self._cmd_list(user_id, chat_id, message_id=message_id)
675
683
  except Exception as e:
@@ -727,16 +735,16 @@ class LarkHandler:
727
735
  logger.error(f"解散群 API 失败: {feishu_msg}")
728
736
 
729
737
  # 无论 Feishu delete 是否成功,都清理本地绑定
730
- self._remove_binding_by_chat(group_chat_id)
731
738
  self._group_chat_ids.discard(group_chat_id)
732
739
  self._save_group_chat_ids()
740
+ self._remove_binding_by_chat(group_chat_id, force=True)
733
741
  await self._detach(group_chat_id)
734
742
 
735
- if feishu_ok:
736
- notice = "✅ 群聊已解散,绑定已解除"
737
- else:
738
- notice = f"⚠️ Feishu 群解散失败({feishu_msg}),已解除本地绑定。如需彻底解散请在飞书群内手动操作"
739
- await card_service.send_text(chat_id, notice)
743
+ if not feishu_ok:
744
+ await card_service.send_text(
745
+ chat_id,
746
+ f"⚠️ Feishu 群解散失败({feishu_msg}),已解除本地绑定。如需彻底解散请在飞书群内手动操作"
747
+ )
740
748
  await self._cmd_list(user_id, chat_id, message_id=message_id)
741
749
  except Exception as e:
742
750
  logger.error(f"解散群失败: {e}")
@@ -755,7 +763,9 @@ class LarkHandler:
755
763
  logger.info(f"自动恢复绑定: chat_id={chat_id[:8]}..., session={saved_session}")
756
764
  ok = await self._attach(chat_id, saved_session)
757
765
  if not ok:
758
- self._remove_binding_by_chat(chat_id)
766
+ self._group_chat_ids.discard(chat_id)
767
+ self._save_group_chat_ids()
768
+ self._remove_binding_by_chat(chat_id, force=True)
759
769
  await card_service.send_text(
760
770
  chat_id, f"会话 '{saved_session}' 已不存在,请重新 /attach"
761
771
  )
@@ -176,6 +176,13 @@ def handle_card_action(event: P2CardActionTrigger) -> P2CardActionTriggerRespons
176
176
  asyncio.create_task(handler._cmd_ls(user_id, chat_id, path, message_id=message_id))
177
177
  return None
178
178
 
179
+ # 菜单卡片:会话列表翻页
180
+ if action_type == "menu_page":
181
+ page = int(action_value.get("page", 0))
182
+ print(f"[Lark] menu_page: page={page}")
183
+ asyncio.create_task(handler._cmd_menu(user_id, chat_id, message_id=message_id, page=page))
184
+ return None
185
+
179
186
  # 目录卡片:翻页
180
187
  if action_type == "dir_page":
181
188
  path = action_value.get("path", "")
@@ -37,7 +37,7 @@ except Exception:
37
37
 
38
38
  # ── 常量 ──────────────────────────────────────────────────────────────────────
39
39
  INITIAL_WINDOW = 30 # 首次 attach 最多显示最近 30 个 blocks
40
- MAX_CARD_BLOCKS = 50 # 单张卡片最多 50 个 blocks → 超限冻结
40
+ from .config import MAX_CARD_BLOCKS # 单张卡片最多 N 个 blocks → 超限冻结(可通过 .env 配置)
41
41
  POLL_INTERVAL = 1.0 # 轮询间隔(秒)
42
42
  RAPID_INTERVAL = 0.2 # 快速轮询间隔(秒)
43
43
  RAPID_DURATION = 2.0 # 快速轮询持续时间(秒)
@@ -210,6 +210,7 @@ class SharedMemoryPoller:
210
210
  bottom_bar = state.get("bottom_bar")
211
211
  agent_panel = state.get("agent_panel")
212
212
  option_block = state.get("option_block")
213
+ cli_type = state.get("cli_type", "claude")
213
214
 
214
215
  # 获取活跃卡片(最后一张且未冻结)
215
216
  active = None
@@ -221,14 +222,24 @@ class SharedMemoryPoller:
221
222
 
222
223
  if active is None:
223
224
  # 需要创建新卡片
224
- await self._create_new_card(tracker, blocks, status_line, bottom_bar, agent_panel, option_block)
225
+ await self._create_new_card(tracker, blocks, status_line, bottom_bar, agent_panel, option_block, cli_type=cli_type)
225
226
  else:
226
227
  # 有活跃卡片,检查是否需要更新
227
228
  blocks_slice = blocks[active.start_idx:]
228
229
 
230
+ # blocks 骤降检测(compact/重启导致 blocks 从头累积)
231
+ if len(blocks) < active.start_idx:
232
+ logger.warning(
233
+ f"[blocks regression] len(blocks)={len(blocks)} < start_idx={active.start_idx}, "
234
+ f"resetting start_idx to 0 (session={tracker.session_name})"
235
+ )
236
+ active.start_idx = 0
237
+ blocks_slice = blocks[0:]
238
+ tracker.content_hash = "" # 强制刷新
239
+
229
240
  # 超限检查
230
241
  if len(blocks_slice) > MAX_CARD_BLOCKS:
231
- await self._freeze_and_split(tracker, blocks, status_line, bottom_bar, agent_panel, option_block)
242
+ await self._freeze_and_split(tracker, blocks, status_line, bottom_bar, agent_panel, option_block, cli_type=cli_type)
232
243
  return
233
244
 
234
245
  # hash diff
@@ -238,7 +249,7 @@ class SharedMemoryPoller:
238
249
 
239
250
  # 更新卡片
240
251
  from .card_builder import build_stream_card
241
- card_dict = build_stream_card(blocks_slice, status_line, bottom_bar, agent_panel=agent_panel, option_block=option_block, session_name=tracker.session_name)
252
+ card_dict = build_stream_card(blocks_slice, status_line, bottom_bar, agent_panel=agent_panel, option_block=option_block, session_name=tracker.session_name, cli_type=cli_type)
242
253
 
243
254
  active.sequence += 1
244
255
  success = await self._card_service.update_card(
@@ -250,7 +261,8 @@ class SharedMemoryPoller:
250
261
  if getattr(success, 'is_element_limit', False):
251
262
  # 元素超限:冻结旧卡 + 推新流式卡
252
263
  await self._handle_element_limit(
253
- tracker, blocks, status_line, bottom_bar, agent_panel, option_block
264
+ tracker, blocks, status_line, bottom_bar, agent_panel, option_block,
265
+ cli_type=cli_type,
254
266
  )
255
267
  return
256
268
  elif not success:
@@ -280,6 +292,7 @@ class SharedMemoryPoller:
280
292
  status_line: Optional[dict], bottom_bar: Optional[dict],
281
293
  agent_panel: Optional[dict] = None,
282
294
  option_block: Optional[dict] = None,
295
+ cli_type: str = "claude",
283
296
  ) -> None:
284
297
  """创建新卡片(首次 attach 或冻结后)"""
285
298
  if not tracker.cards:
@@ -289,13 +302,19 @@ class SharedMemoryPoller:
289
302
  # 冻结后:从上张冻结卡片的结束位置开始
290
303
  last_frozen = tracker.cards[-1]
291
304
  start_idx = last_frozen.start_idx + MAX_CARD_BLOCKS
305
+ if start_idx >= len(blocks):
306
+ start_idx = 0
307
+ logger.warning(
308
+ f"[_create_new_card] start_idx overflow, reset to 0 "
309
+ f"(frozen.start_idx={last_frozen.start_idx}, total blocks={len(blocks)})"
310
+ )
292
311
 
293
312
  blocks_slice = blocks[start_idx:]
294
313
  if not blocks_slice and not status_line and not bottom_bar and not agent_panel and not option_block:
295
314
  return
296
315
 
297
316
  from .card_builder import build_stream_card
298
- card_dict = build_stream_card(blocks_slice, status_line, bottom_bar, agent_panel=agent_panel, option_block=option_block, session_name=tracker.session_name)
317
+ card_dict = build_stream_card(blocks_slice, status_line, bottom_bar, agent_panel=agent_panel, option_block=option_block, session_name=tracker.session_name, cli_type=cli_type)
299
318
  card_id = await self._card_service.create_card(card_dict)
300
319
 
301
320
  if card_id:
@@ -316,6 +335,7 @@ class SharedMemoryPoller:
316
335
  status_line: Optional[dict], bottom_bar: Optional[dict],
317
336
  agent_panel: Optional[dict] = None,
318
337
  option_block: Optional[dict] = None,
338
+ cli_type: str = "claude",
319
339
  ) -> None:
320
340
  """元素超限:冻结旧卡片 + 推送新流式卡片"""
321
341
  active = tracker.cards[-1]
@@ -340,6 +360,7 @@ class SharedMemoryPoller:
340
360
  new_blocks, status_line, bottom_bar,
341
361
  agent_panel=agent_panel, option_block=option_block,
342
362
  session_name=tracker.session_name,
363
+ cli_type=cli_type,
343
364
  )
344
365
  new_card_id = await self._card_service.create_card(new_card_dict)
345
366
  if new_card_id:
@@ -358,6 +379,7 @@ class SharedMemoryPoller:
358
379
  status_line: Optional[dict], bottom_bar: Optional[dict],
359
380
  agent_panel: Optional[dict] = None,
360
381
  option_block: Optional[dict] = None,
382
+ cli_type: str = "claude",
361
383
  ) -> None:
362
384
  """冻结当前卡片 + 开新卡"""
363
385
  active = tracker.cards[-1]
@@ -382,7 +404,7 @@ class SharedMemoryPoller:
382
404
  if not new_blocks:
383
405
  return
384
406
 
385
- new_card_dict = build_stream_card(new_blocks, status_line, bottom_bar, agent_panel=agent_panel, option_block=option_block, session_name=tracker.session_name)
407
+ new_card_dict = build_stream_card(new_blocks, status_line, bottom_bar, agent_panel=agent_panel, option_block=option_block, session_name=tracker.session_name, cli_type=cli_type)
386
408
  new_card_id = await self._card_service.create_card(new_card_dict)
387
409
  if new_card_id:
388
410
  await self._card_service.send_card(tracker.chat_id, new_card_id)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "remote-claude",
3
- "version": "0.2.7",
3
+ "version": "0.2.8",
4
4
  "description": "双端共享 Claude CLI 工具",
5
5
  "bin": {
6
6
  "remote-claude": "bin/remote-claude",
@@ -24,7 +24,6 @@
24
24
  "stats/__init__.py",
25
25
  "stats/*.py",
26
26
  "pyproject.toml",
27
- "uv.lock",
28
27
  ".env.example"
29
28
  ],
30
29
  "os": [
package/remote_claude.py CHANGED
@@ -79,6 +79,8 @@ def cmd_start(args):
79
79
  claude_args_str = " ".join(f"'{arg}'" for arg in claude_args)
80
80
  debug_flag = " --debug-screen" if getattr(args, "debug_screen", False) else ""
81
81
  debug_verbose_flag = " --debug-verbose" if getattr(args, "debug_verbose", False) else ""
82
+ cli_type = getattr(args, "cli", "claude")
83
+ cli_type_flag = f" --cli-type {cli_type}" if cli_type != "claude" else ""
82
84
 
83
85
  # 捕获用户终端环境变量(tmux 会覆盖这些值,导致 Claude CLI 无法启用 kitty keyboard protocol)
84
86
  env_prefix = ""
@@ -87,7 +89,7 @@ def cmd_start(args):
87
89
  if val:
88
90
  env_prefix += f"{key}='{val}' "
89
91
 
90
- server_cmd = f"{env_prefix}uv run --project '{SCRIPT_DIR}' python3 '{server_script}'{debug_flag}{debug_verbose_flag} -- '{session_name}' {claude_args_str}"
92
+ 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}"
91
93
 
92
94
  print(f"启动会话: {session_name}")
93
95
 
@@ -500,6 +502,12 @@ def main():
500
502
  action="store_true",
501
503
  help="debug 日志输出完整诊断信息(indicator、repr 等),默认只输出 ansi_render"
502
504
  )
505
+ start_parser.add_argument(
506
+ "--cli",
507
+ default="claude",
508
+ choices=["claude", "codex"],
509
+ help="后端 CLI 类型(默认 claude)"
510
+ )
503
511
  start_parser.set_defaults(func=cmd_start)
504
512
 
505
513
  # attach 命令