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.
- package/.env.example +4 -0
- package/README.md +12 -136
- package/bin/cdx +9 -1
- package/bin/cl +9 -1
- package/bin/cla +9 -1
- package/bin/cx +9 -1
- package/bin/remote-claude +9 -23
- package/client/client.py +1 -1
- package/init.sh +30 -97
- package/lark_client/card_builder.py +53 -33
- package/lark_client/lark_handler.py +159 -42
- package/lark_client/main.py +6 -4
- package/lark_client/session_bridge.py +2 -2
- package/lark_client/setup_wizard.py +999 -0
- package/lark_client/shared_memory_poller.py +137 -112
- package/package.json +1 -1
- package/remote_claude.py +251 -0
- package/server/server.py +31 -19
- package/utils/session.py +81 -30
- package/scripts/check-env.sh +0 -43
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
887
|
-
"""
|
|
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
|
-
|
|
891
|
-
|
|
892
|
-
|
|
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.
|
|
936
|
-
if not bridge
|
|
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.
|
|
1055
|
-
if not bridge
|
|
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)
|
package/lark_client/main.py
CHANGED
|
@@ -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
|
-
|
|
254
|
-
|
|
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
|
-
|
|
262
|
-
|
|
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.
|
|
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
|
|