remote-claude 1.0.3 → 1.0.5

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.
@@ -153,8 +153,9 @@ class LarkHandler:
153
153
  self._chat_sessions.pop(chat_id, None)
154
154
  self._detached_slices.pop(chat_id, None)
155
155
 
156
- def on_disconnect():
157
- asyncio.create_task(self._on_disconnect(chat_id, session_name))
156
+ def on_disconnect(disconnected_bridge=None):
157
+ asyncio.create_task(self._on_disconnect(chat_id, session_name,
158
+ disconnected_bridge=disconnected_bridge))
158
159
 
159
160
  bridge = SessionBridge(session_name, on_disconnect=on_disconnect)
160
161
  if await bridge.connect():
@@ -175,8 +176,15 @@ class LarkHandler:
175
176
  self._chat_sessions.pop(chat_id, None)
176
177
  self._poller.stop(chat_id)
177
178
 
178
- async def _on_disconnect(self, chat_id: str, session_name: str):
179
+ async def _on_disconnect(self, chat_id: str, session_name: str,
180
+ disconnected_bridge=None):
179
181
  """服务端关闭连接时的统一处理"""
182
+ # 防竞态:若当前 bridge 已被 _attach 替换为新实例,跳过清理
183
+ current_bridge = self._bridges.get(chat_id)
184
+ if disconnected_bridge is not None and current_bridge is not disconnected_bridge:
185
+ logger.info(f"会话 '{session_name}' 旧连接断线(已被替换),跳过清理")
186
+ return
187
+
180
188
  logger.info(f"会话 '{session_name}' 断线, chat_id={chat_id[:8]}...")
181
189
  _track_stats('lark', 'disconnect', session_name=session_name,
182
190
  chat_id=chat_id)
@@ -184,14 +192,12 @@ class LarkHandler:
184
192
  self._bridges.pop(chat_id, None)
185
193
  self._chat_sessions.pop(chat_id, None)
186
194
  self._detached_slices.pop(chat_id, None)
187
- self._remove_binding_by_chat(chat_id)
195
+ # 注意:不清除持久化绑定,socket 断连是临时状态,下次操作可自动恢复
196
+ # 只有用户主动 /detach 或 /kill 时才清除绑定
188
197
 
189
198
  if active_slice:
190
199
  await self._update_card_disconnected(chat_id, session_name, active_slice)
191
200
 
192
- # 会话退出时自动解散绑定到该会话的所有专属群聊
193
- await self._disband_groups_for_session(session_name, source="disconnect")
194
-
195
201
  # ── 消息入口 ────────────────────────────────────────────────────────────
196
202
 
197
203
  async def handle_message(self, user_id: str, chat_id: str, text: str,
@@ -249,6 +255,8 @@ class LarkHandler:
249
255
  await self._cmd_help(user_id, chat_id)
250
256
  elif command == "/menu":
251
257
  await self._cmd_menu(user_id, chat_id)
258
+ elif command == "/press":
259
+ await self._cmd_press(user_id, chat_id, args)
252
260
  else:
253
261
  await card_service.send_text(chat_id, f"未知命令: {command}\n使用 /help 查看帮助")
254
262
 
@@ -306,7 +314,7 @@ class LarkHandler:
306
314
  if card_id:
307
315
  await card_service.send_card(chat_id, card_id)
308
316
 
309
- async def _cmd_start(self, user_id: str, chat_id: str, args: str):
317
+ async def _cmd_start(self, user_id: str, chat_id: str, args: str, cli_type: str = "claude"):
310
318
  """启动新会话"""
311
319
  parts = args.strip().split(maxsplit=1)
312
320
  if not parts:
@@ -348,10 +356,15 @@ class LarkHandler:
348
356
  script_dir = Path(__file__).parent.parent.absolute()
349
357
  server_script = script_dir / "server" / "server.py"
350
358
  cmd = ["uv", "run", "--project", str(script_dir), "python3", str(server_script), session_name]
359
+ if cli_type == "codex":
360
+ cmd += ["--cli-type", "codex"]
351
361
  if self._poller.get_bypass_enabled():
352
- cmd += ["--", "--dangerously-skip-permissions", "--permission-mode=dontAsk"]
362
+ if cli_type == "codex":
363
+ cmd += ["--", "--dangerously-bypass-approvals-and-sandbox"]
364
+ else:
365
+ cmd += ["--", "--dangerously-skip-permissions", "--permission-mode=dontAsk"]
353
366
 
354
- logger.info(f"启动会话: {session_name}, 工作目录: {work_dir}, 命令: {' '.join(cmd)}")
367
+ logger.info(f"启动会话: {session_name}, 工作目录: {work_dir}, cli_type: {cli_type}, 命令: {' '.join(cmd)}")
355
368
  _track_stats('lark', 'cmd_start', session_name=session_name, chat_id=chat_id)
356
369
 
357
370
  try:
@@ -408,7 +421,7 @@ class LarkHandler:
408
421
  self._starting_sessions.discard(session_name)
409
422
 
410
423
  async def _cmd_start_and_new_group(self, user_id: str, chat_id: str,
411
- session_name: str, path: str):
424
+ session_name: str, path: str, cli_type: str = "claude"):
412
425
  """在指定目录启动会话并创建专属群聊"""
413
426
  work_path = Path(path).expanduser()
414
427
  if not work_path.is_dir():
@@ -426,8 +439,13 @@ class LarkHandler:
426
439
  script_dir = Path(__file__).parent.parent.absolute()
427
440
  server_script = script_dir / "server" / "server.py"
428
441
  cmd = ["uv", "run", "--project", str(script_dir), "python3", str(server_script), session_name]
442
+ if cli_type == "codex":
443
+ cmd += ["--cli-type", "codex"]
429
444
  if self._poller.get_bypass_enabled():
430
- cmd += ["--", "--dangerously-skip-permissions", "--permission-mode=dontAsk"]
445
+ if cli_type == "codex":
446
+ cmd += ["--", "--dangerously-bypass-approvals-and-sandbox"]
447
+ else:
448
+ cmd += ["--", "--dangerously-skip-permissions", "--permission-mode=dontAsk"]
431
449
 
432
450
  try:
433
451
  env = _os.environ.copy()
@@ -732,7 +750,8 @@ class LarkHandler:
732
750
  session = next((s for s in sessions if s["name"] == session_name), None)
733
751
  pid = session.get("pid") if session else None
734
752
  cwd = self._get_pid_cwd(pid) if pid else None
735
- dir_label = cwd.rstrip("/").rsplit("/", 1)[-1] if cwd else session_name
753
+ from .card_builder import _get_display_name
754
+ dir_label = _get_display_name(session_name, cwd)
736
755
 
737
756
  from . import config
738
757
  try:
@@ -883,34 +902,33 @@ class LarkHandler:
883
902
 
884
903
  # ── 消息转发 ─────────────────────────────────────────────────────────────
885
904
 
886
- async def _forward_to_claude(self, user_id: str, chat_id: str, text: str):
887
- """转发消息给 Claude(输出由 SharedMemoryPoller 自动推卡片)"""
905
+ async def _ensure_bridge(self, chat_id: str, user_id: str = None):
906
+ """获取 bridge,断连时尝试从持久化绑定自动恢复"""
888
907
  bridge = self._bridges.get(chat_id)
908
+ if bridge and bridge.running:
909
+ return bridge
910
+ # 尝试从持久化绑定恢复
911
+ saved_session = self._chat_bindings.get(chat_id)
912
+ if saved_session:
913
+ logger.info(f"自动恢复绑定: chat_id={chat_id[:8]}..., session={saved_session}")
914
+ ok = await self._attach(chat_id, saved_session, user_id=user_id)
915
+ if ok:
916
+ return self._bridges.get(chat_id)
917
+ # 恢复失败:会话已不存在,清除绑定
918
+ self._group_chat_ids.discard(chat_id)
919
+ self._save_group_chat_ids()
920
+ self._remove_binding_by_chat(chat_id, force=True)
921
+ await self._disband_groups_for_session(saved_session, source="lazy")
922
+ return None
889
923
 
890
- if not bridge or not bridge.running:
891
- # 尝试从持久化绑定自动恢复
892
- saved_session = self._chat_bindings.get(chat_id)
893
- if saved_session:
894
- logger.info(f"自动恢复绑定: chat_id={chat_id[:8]}..., session={saved_session}")
895
- ok = await self._attach(chat_id, saved_session, user_id=user_id)
896
- if not ok:
897
- self._group_chat_ids.discard(chat_id)
898
- self._save_group_chat_ids()
899
- self._remove_binding_by_chat(chat_id, force=True)
900
- await card_service.send_text(
901
- chat_id, f"会话 '{saved_session}' 已不存在,请重新 /attach"
902
- )
903
- # 会话已不存在,解散绑定到该会话的所有专属群聊
904
- await self._disband_groups_for_session(saved_session, source="lazy")
905
- return
906
- bridge = self._bridges.get(chat_id)
907
- else:
908
- await card_service.send_text(
909
- chat_id, "未连接到任何会话,请先使用 /attach <会话名> 连接"
910
- )
911
- return
924
+ async def _forward_to_claude(self, user_id: str, chat_id: str, text: str):
925
+ """转发消息给 Claude(输出由 SharedMemoryPoller 自动推卡片)"""
926
+ bridge = await self._ensure_bridge(chat_id, user_id=user_id)
912
927
 
913
928
  if not bridge:
929
+ await card_service.send_text(
930
+ chat_id, "未连接到任何会话,请先使用 /attach <会话名> 连接"
931
+ )
914
932
  return
915
933
 
916
934
  success = await bridge.send_input(text)
@@ -932,8 +950,8 @@ class LarkHandler:
932
950
  session_name=self._chat_sessions.get(chat_id, ''),
933
951
  chat_id=chat_id, detail=option_value)
934
952
 
935
- bridge = self._bridges.get(chat_id)
936
- if not bridge or not bridge.running:
953
+ bridge = await self._ensure_bridge(chat_id, user_id=user_id)
954
+ if not bridge:
937
955
  await card_service.send_text(chat_id, "未连接到任何会话,请先使用 /attach <会话名> 连接")
938
956
  return
939
957
 
@@ -1033,6 +1051,105 @@ class LarkHandler:
1033
1051
 
1034
1052
  # ── 快捷键发送 ─────────────────────────────────────────────────────────────
1035
1053
 
1054
+ @staticmethod
1055
+ def _parse_key_combo(combo: str) -> Optional[bytes]:
1056
+ """将用户输入的按键字符串解析为终端转义序列字节,解析失败返回 None"""
1057
+ BASE_KEY_MAP = {
1058
+ "up": b"\x1b[A",
1059
+ "down": b"\x1b[B",
1060
+ "right": b"\x1b[C",
1061
+ "left": b"\x1b[D",
1062
+ "enter": b"\r",
1063
+ "esc": b"\x1b",
1064
+ "tab": b"\t",
1065
+ "backspace": b"\x7f",
1066
+ "delete": b"\x1b[3~",
1067
+ "space": b" ",
1068
+ "home": b"\x1b[H",
1069
+ "end": b"\x1b[F",
1070
+ "pageup": b"\x1b[5~",
1071
+ "pagedown": b"\x1b[6~",
1072
+ "f1": b"\x1bOP", "f2": b"\x1bOQ", "f3": b"\x1bOR", "f4": b"\x1bOS",
1073
+ "f5": b"\x1b[15~", "f6": b"\x1b[17~","f7": b"\x1b[18~","f8": b"\x1b[19~",
1074
+ "f9": b"\x1b[20~", "f10": b"\x1b[21~","f11": b"\x1b[23~","f12": b"\x1b[24~",
1075
+ }
1076
+
1077
+ s = combo.strip().lower()
1078
+ parts = [p.strip() for p in s.split("+")]
1079
+
1080
+ mods = set()
1081
+ keys = []
1082
+ for p in parts:
1083
+ if p in ("ctrl", "alt", "shift"):
1084
+ mods.add(p)
1085
+ else:
1086
+ keys.append(p)
1087
+
1088
+ if len(keys) != 1:
1089
+ return None
1090
+ key = keys[0]
1091
+
1092
+ # ctrl+letter → \x01-\x1a
1093
+ if mods == {"ctrl"}:
1094
+ if len(key) == 1 and 'a' <= key <= 'z':
1095
+ return bytes([ord(key) - ord('a') + 1])
1096
+ # ctrl+[ = ESC, ctrl+\ = FS 等特殊控制字符
1097
+ ctrl_special = {'[': b'\x1b', '\\': b'\x1c', ']': b'\x1d', '^': b'\x1e', '_': b'\x1f'}
1098
+ if key in ctrl_special:
1099
+ return ctrl_special[key]
1100
+ return None
1101
+
1102
+ # alt+key → ESC prefix
1103
+ if mods == {"alt"}:
1104
+ base = BASE_KEY_MAP.get(key)
1105
+ if base:
1106
+ return b"\x1b" + base
1107
+ if len(key) == 1:
1108
+ return b"\x1b" + key.encode()
1109
+ return None
1110
+
1111
+ # shift+tab / shift+enter
1112
+ if mods == {"shift"}:
1113
+ if key == "tab":
1114
+ return b"\x1b[Z"
1115
+ if key == "enter":
1116
+ return b"\x1b[13;2u"
1117
+ return None
1118
+
1119
+ # 无修饰键
1120
+ if not mods:
1121
+ return BASE_KEY_MAP.get(key)
1122
+
1123
+ return None
1124
+
1125
+ async def _cmd_press(self, user_id: str, chat_id: str, args: str):
1126
+ """发送任意按键组合到会话"""
1127
+ combo = args.strip()
1128
+ if not combo:
1129
+ await card_service.send_text(
1130
+ chat_id,
1131
+ "用法:`/press <按键>`\n"
1132
+ "例如:`/press ctrl+c`、`/press esc`、`/press ctrl+f`、`/press alt+x`\n"
1133
+ "支持:ctrl/alt/shift 修饰键,方向键 up/down/left/right,enter/esc/tab/backspace/delete/space/home/end/pageup/pagedown/f1-f12"
1134
+ )
1135
+ return
1136
+
1137
+ raw = LarkHandler._parse_key_combo(combo)
1138
+ if raw is None:
1139
+ await card_service.send_text(chat_id, f"❌ 无法解析按键:`{combo}`\n使用 `/press` 查看支持的按键格式")
1140
+ return
1141
+
1142
+ bridge = self._bridges.get(chat_id)
1143
+ if not bridge or not bridge.running:
1144
+ await card_service.send_text(chat_id, "❌ 当前未连接到会话,请先使用 /attach 连接")
1145
+ return
1146
+
1147
+ success = await bridge.send_raw(raw)
1148
+ if success:
1149
+ logger.info(f"[press] 发送按键 {combo!r} ({raw!r}) 到会话")
1150
+ else:
1151
+ await card_service.send_text(chat_id, f"❌ 发送按键 `{combo}` 失败")
1152
+
1036
1153
  async def send_raw_key(self, user_id: str, chat_id: str, key_name: str):
1037
1154
  """发送原始控制键到 Claude CLI"""
1038
1155
  _track_stats('lark', 'raw_key',
@@ -1051,9 +1168,9 @@ class LarkHandler:
1051
1168
  logger.warning(f"未知快捷键: {key_name}")
1052
1169
  return
1053
1170
 
1054
- bridge = self._bridges.get(chat_id)
1055
- if not bridge or not bridge.running:
1056
- logger.warning(f"send_raw_key: chat_id={chat_id[:8]}... 未连接会话")
1171
+ bridge = await self._ensure_bridge(chat_id, user_id=user_id)
1172
+ if not bridge:
1173
+ logger.warning(f"send_raw_key: chat_id={chat_id[:8]}... 未连接会话(自动恢复失败)")
1057
1174
  return
1058
1175
 
1059
1176
  success = await bridge.send_raw(raw)
@@ -250,16 +250,18 @@ def handle_card_action(event: P2CardActionTrigger) -> P2CardActionTriggerRespons
250
250
  if action_type == "dir_start":
251
251
  path = action_value.get("path", "")
252
252
  session_name = action_value.get("session_name", "")
253
- print(f"[Lark] dir_start: path={path}, session={session_name}")
254
- asyncio.create_task(handler._cmd_start(user_id, chat_id, f"{session_name} {path}"))
253
+ cli_type = action_value.get("cli_type", "claude")
254
+ print(f"[Lark] dir_start: path={path}, session={session_name}, cli_type={cli_type}")
255
+ asyncio.create_task(handler._cmd_start(user_id, chat_id, f"{session_name} {path}", cli_type=cli_type))
255
256
  return None
256
257
 
257
258
  # 目录卡片:在该目录启动会话并创建专属群聊
258
259
  if action_type == "dir_new_group":
259
260
  path = action_value.get("path", "")
260
261
  session_name = action_value.get("session_name", "")
261
- print(f"[Lark] dir_new_group: path={path}, session={session_name}")
262
- asyncio.create_task(handler._cmd_start_and_new_group(user_id, chat_id, session_name, path))
262
+ cli_type = action_value.get("cli_type", "claude")
263
+ print(f"[Lark] dir_new_group: path={path}, session={session_name}, cli_type={cli_type}")
264
+ asyncio.create_task(handler._cmd_start_and_new_group(user_id, chat_id, session_name, path, cli_type=cli_type))
263
265
  return None
264
266
 
265
267
  # /menu 卡片按钮
@@ -183,12 +183,12 @@ class SessionBridge:
183
183
  except asyncio.CancelledError:
184
184
  break
185
185
  except Exception as e:
186
- logger.debug(f"读取错误: {e}")
186
+ logger.warning(f"读取错误: {e}")
187
187
  break
188
188
 
189
189
  if self.on_disconnect and not self._manually_disconnected:
190
190
  try:
191
- self.on_disconnect()
191
+ self.on_disconnect(self) # 传递 bridge 实例,供上层验证身份
192
192
  except Exception as e:
193
193
  logger.error(f"on_disconnect 回调异常: {e}")
194
194