remote-claude 1.0.1 → 1.0.3
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/lark_client/card_builder.py +23 -7
- package/lark_client/config.py +4 -0
- package/lark_client/lark_handler.py +212 -79
- package/lark_client/main.py +24 -2
- package/lark_client/session_bridge.py +4 -10
- package/lark_client/shared_memory_poller.py +95 -4
- package/package.json +2 -1
- package/pyproject.toml +1 -0
- package/scripts/preinstall.sh +4 -0
- package/server/parsers/claude_parser.py +43 -0
- package/server/parsers/codex_parser.py +8 -0
- package/utils/components.py +1 -0
package/.env.example
CHANGED
|
@@ -427,6 +427,7 @@ def _build_buttons_v2(options: List[Dict[str, str]]) -> List[Dict[str, Any]]:
|
|
|
427
427
|
"value": {
|
|
428
428
|
"action": "select_option",
|
|
429
429
|
"value": opt["value"],
|
|
430
|
+
"needs_input": opt.get("needs_input", False),
|
|
430
431
|
"total": str(total),
|
|
431
432
|
}
|
|
432
433
|
}
|
|
@@ -1022,16 +1023,22 @@ def build_dir_card(target, entries: List[Dict], sessions: List[Dict], tree: bool
|
|
|
1022
1023
|
|
|
1023
1024
|
if is_dir and depth == 0:
|
|
1024
1025
|
auto_session = _dir_session_name(full_path)
|
|
1026
|
+
# 前缀匹配:找 session_groups 中精确匹配或以 auto_session + "_" 开头的条目(取最后一个,即最新的)
|
|
1027
|
+
matched_group_cid = None
|
|
1028
|
+
if session_groups:
|
|
1029
|
+
for sn, cid in session_groups.items():
|
|
1030
|
+
if sn == auto_session or sn.startswith(auto_session + "_"):
|
|
1031
|
+
matched_group_cid = cid
|
|
1025
1032
|
group_btn = {
|
|
1026
1033
|
"tag": "button",
|
|
1027
|
-
"text": {"tag": "plain_text", "content": "进入群聊" if
|
|
1034
|
+
"text": {"tag": "plain_text", "content": "进入群聊" if matched_group_cid else "创建群聊"},
|
|
1028
1035
|
"type": "default",
|
|
1029
1036
|
"behaviors": [{"type": "open_url",
|
|
1030
|
-
"default_url": f"https://applink.feishu.cn/client/chat/open?openChatId={
|
|
1031
|
-
"android_url": f"https://applink.feishu.cn/client/chat/open?openChatId={
|
|
1032
|
-
"ios_url": f"https://applink.feishu.cn/client/chat/open?openChatId={
|
|
1033
|
-
"pc_url": f"https://applink.feishu.cn/client/chat/open?openChatId={
|
|
1034
|
-
if
|
|
1037
|
+
"default_url": f"https://applink.feishu.cn/client/chat/open?openChatId={matched_group_cid}",
|
|
1038
|
+
"android_url": f"https://applink.feishu.cn/client/chat/open?openChatId={matched_group_cid}",
|
|
1039
|
+
"ios_url": f"https://applink.feishu.cn/client/chat/open?openChatId={matched_group_cid}",
|
|
1040
|
+
"pc_url": f"https://applink.feishu.cn/client/chat/open?openChatId={matched_group_cid}"}]
|
|
1041
|
+
if matched_group_cid else
|
|
1035
1042
|
[{"type": "callback", "value": {
|
|
1036
1043
|
"action": "dir_new_group",
|
|
1037
1044
|
"path": full_path,
|
|
@@ -1202,7 +1209,8 @@ def build_session_closed_card(session_name: str) -> Dict[str, Any]:
|
|
|
1202
1209
|
|
|
1203
1210
|
def build_menu_card(sessions: List[Dict], current_session: Optional[str] = None,
|
|
1204
1211
|
session_groups: Optional[Dict[str, str]] = None, page: int = 0,
|
|
1205
|
-
notify_enabled: bool = True, urgent_enabled: bool = False
|
|
1212
|
+
notify_enabled: bool = True, urgent_enabled: bool = False,
|
|
1213
|
+
bypass_enabled: bool = False) -> Dict[str, Any]:
|
|
1206
1214
|
"""构建快捷操作菜单卡片(/menu 和 /list 共用):内嵌会话列表 + 快捷操作"""
|
|
1207
1215
|
elements = []
|
|
1208
1216
|
|
|
@@ -1301,6 +1309,14 @@ def build_menu_card(sessions: List[Dict], current_session: Optional[str] = None,
|
|
|
1301
1309
|
]
|
|
1302
1310
|
})
|
|
1303
1311
|
|
|
1312
|
+
bypass_label = "🔓 新会话bypass: 开" if bypass_enabled else "🔒 新会话bypass: 关"
|
|
1313
|
+
elements.append({
|
|
1314
|
+
"tag": "button",
|
|
1315
|
+
"text": {"tag": "plain_text", "content": bypass_label},
|
|
1316
|
+
"type": "default",
|
|
1317
|
+
"behaviors": [{"type": "callback", "value": {"action": "menu_toggle_bypass"}}]
|
|
1318
|
+
})
|
|
1319
|
+
|
|
1304
1320
|
return {
|
|
1305
1321
|
"schema": "2.0",
|
|
1306
1322
|
"config": {"wide_screen_mode": True},
|
package/lark_client/config.py
CHANGED
|
@@ -11,8 +11,11 @@
|
|
|
11
11
|
import asyncio
|
|
12
12
|
import json
|
|
13
13
|
import logging
|
|
14
|
+
import os as _os
|
|
14
15
|
import subprocess
|
|
15
16
|
import sys
|
|
17
|
+
import time
|
|
18
|
+
from datetime import datetime as _datetime
|
|
16
19
|
from pathlib import Path
|
|
17
20
|
from typing import Optional, Dict, Any, List
|
|
18
21
|
|
|
@@ -30,7 +33,23 @@ from .card_builder import (
|
|
|
30
33
|
from .shared_memory_poller import SharedMemoryPoller, CardSlice
|
|
31
34
|
|
|
32
35
|
sys.path.insert(0, str(Path(__file__).parent.parent))
|
|
33
|
-
from utils.session import list_active_sessions, get_socket_path, get_chat_bindings_file, ensure_user_data_dir
|
|
36
|
+
from utils.session import list_active_sessions, get_socket_path, get_chat_bindings_file, ensure_user_data_dir, USER_DATA_DIR
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _read_log_since(since: '_datetime', log_path: 'Path') -> str:
|
|
40
|
+
"""读取 startup.log 中 since 时间点之后的日志行"""
|
|
41
|
+
if not log_path.exists():
|
|
42
|
+
return ""
|
|
43
|
+
lines = []
|
|
44
|
+
for line in log_path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
45
|
+
try:
|
|
46
|
+
ts = _datetime.strptime(line[:23], "%Y-%m-%d %H:%M:%S.%f")
|
|
47
|
+
if ts >= since:
|
|
48
|
+
lines.append(line)
|
|
49
|
+
except ValueError:
|
|
50
|
+
if lines:
|
|
51
|
+
lines.append(line)
|
|
52
|
+
return "\n".join(lines)
|
|
34
53
|
|
|
35
54
|
try:
|
|
36
55
|
from stats import track as _track_stats
|
|
@@ -66,6 +85,8 @@ class LarkHandler:
|
|
|
66
85
|
self._group_chat_ids: set = self._load_group_chat_ids()
|
|
67
86
|
# chat_id → CardSlice(用户主动断开后保留,供重连时冻结旧卡片)
|
|
68
87
|
self._detached_slices: Dict[str, CardSlice] = {}
|
|
88
|
+
# 正在启动中的会话名集合(防止并发点击触发竞态)
|
|
89
|
+
self._starting_sessions: set = set()
|
|
69
90
|
|
|
70
91
|
# ── 持久化绑定 ──────────────────────────────────────────────────────────
|
|
71
92
|
|
|
@@ -168,6 +189,9 @@ class LarkHandler:
|
|
|
168
189
|
if active_slice:
|
|
169
190
|
await self._update_card_disconnected(chat_id, session_name, active_slice)
|
|
170
191
|
|
|
192
|
+
# 会话退出时自动解散绑定到该会话的所有专属群聊
|
|
193
|
+
await self._disband_groups_for_session(session_name, source="disconnect")
|
|
194
|
+
|
|
171
195
|
# ── 消息入口 ────────────────────────────────────────────────────────────
|
|
172
196
|
|
|
173
197
|
async def handle_message(self, user_id: str, chat_id: str, text: str,
|
|
@@ -316,45 +340,36 @@ class LarkHandler:
|
|
|
316
340
|
)
|
|
317
341
|
return
|
|
318
342
|
|
|
343
|
+
if session_name in self._starting_sessions:
|
|
344
|
+
await card_service.send_text(chat_id, f"会话 '{session_name}' 正在启动中,请稍候")
|
|
345
|
+
return
|
|
346
|
+
self._starting_sessions.add(session_name)
|
|
347
|
+
|
|
319
348
|
script_dir = Path(__file__).parent.parent.absolute()
|
|
320
349
|
server_script = script_dir / "server" / "server.py"
|
|
321
|
-
cmd = [
|
|
350
|
+
cmd = ["uv", "run", "--project", str(script_dir), "python3", str(server_script), session_name]
|
|
351
|
+
if self._poller.get_bypass_enabled():
|
|
352
|
+
cmd += ["--", "--dangerously-skip-permissions", "--permission-mode=dontAsk"]
|
|
322
353
|
|
|
323
354
|
logger.info(f"启动会话: {session_name}, 工作目录: {work_dir}, 命令: {' '.join(cmd)}")
|
|
324
355
|
_track_stats('lark', 'cmd_start', session_name=session_name, chat_id=chat_id)
|
|
325
356
|
|
|
326
357
|
try:
|
|
327
|
-
import os as _os
|
|
328
|
-
from datetime import datetime as _datetime
|
|
329
358
|
env = _os.environ.copy()
|
|
330
359
|
env.pop("CLAUDECODE", None)
|
|
331
360
|
|
|
332
|
-
from utils.session import USER_DATA_DIR
|
|
333
361
|
log_path = USER_DATA_DIR / "startup.log"
|
|
334
362
|
start_time = _datetime.now()
|
|
335
363
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
def _read_log_since(since):
|
|
346
|
-
if not log_path.exists():
|
|
347
|
-
return ""
|
|
348
|
-
lines = []
|
|
349
|
-
for line in log_path.read_text(encoding="utf-8").splitlines():
|
|
350
|
-
try:
|
|
351
|
-
ts = _datetime.strptime(line[:23], "%Y-%m-%d %H:%M:%S.%f")
|
|
352
|
-
if ts >= since:
|
|
353
|
-
lines.append(line)
|
|
354
|
-
except ValueError:
|
|
355
|
-
if lines:
|
|
356
|
-
lines.append(line)
|
|
357
|
-
return "\n".join(lines)
|
|
364
|
+
with open(log_path, 'a') as stderr_fd:
|
|
365
|
+
proc = subprocess.Popen(
|
|
366
|
+
cmd,
|
|
367
|
+
stdout=subprocess.DEVNULL,
|
|
368
|
+
stderr=stderr_fd,
|
|
369
|
+
start_new_session=True,
|
|
370
|
+
cwd=work_dir,
|
|
371
|
+
env=env,
|
|
372
|
+
)
|
|
358
373
|
|
|
359
374
|
socket_path = get_socket_path(session_name)
|
|
360
375
|
for i in range(120):
|
|
@@ -365,13 +380,13 @@ class LarkHandler:
|
|
|
365
380
|
elapsed = (i + 1) // 10
|
|
366
381
|
rc = proc.poll()
|
|
367
382
|
if rc is not None:
|
|
368
|
-
log_content = _read_log_since(start_time)
|
|
383
|
+
log_content = _read_log_since(start_time, log_path)
|
|
369
384
|
logger.warning(f"会话启动失败: server 进程已退出 (exitcode={rc}, elapsed={elapsed}s)\n{log_content}")
|
|
370
385
|
await card_service.send_text(chat_id, f"错误: Server 进程意外退出 (code={rc})\n\n{log_content}")
|
|
371
386
|
return
|
|
372
387
|
logger.info(f"等待 server socket... ({elapsed}s)")
|
|
373
388
|
else:
|
|
374
|
-
log_content = _read_log_since(start_time)
|
|
389
|
+
log_content = _read_log_since(start_time, log_path)
|
|
375
390
|
logger.error(f"会话启动超时 (12s), session={session_name}\n{log_content}")
|
|
376
391
|
await card_service.send_text(chat_id, f"错误: 会话启动超时 (12s)\n\n{log_content}")
|
|
377
392
|
return
|
|
@@ -389,42 +404,61 @@ class LarkHandler:
|
|
|
389
404
|
except Exception as e:
|
|
390
405
|
logger.error(f"启动会话失败: {e}")
|
|
391
406
|
await card_service.send_text(chat_id, f"错误: 启动失败 - {e}")
|
|
407
|
+
finally:
|
|
408
|
+
self._starting_sessions.discard(session_name)
|
|
392
409
|
|
|
393
410
|
async def _cmd_start_and_new_group(self, user_id: str, chat_id: str,
|
|
394
411
|
session_name: str, path: str):
|
|
395
412
|
"""在指定目录启动会话并创建专属群聊"""
|
|
396
|
-
sessions = list_active_sessions()
|
|
397
|
-
if any(s["name"] == session_name for s in sessions):
|
|
398
|
-
# 会话已存在,直接创建群聊
|
|
399
|
-
await self._cmd_new_group(user_id, chat_id, session_name)
|
|
400
|
-
return
|
|
401
|
-
|
|
402
413
|
work_path = Path(path).expanduser()
|
|
403
414
|
if not work_path.is_dir():
|
|
404
415
|
await card_service.send_text(chat_id, f"错误: 路径无效: {path}")
|
|
405
416
|
return
|
|
406
417
|
|
|
418
|
+
sessions = list_active_sessions()
|
|
419
|
+
active_names = {s["name"] for s in sessions}
|
|
420
|
+
if session_name in active_names or session_name in self._starting_sessions:
|
|
421
|
+
session_name = f"{session_name}_{_datetime.now().strftime('%m%d_%H%M%S')}"
|
|
422
|
+
|
|
423
|
+
self._starting_sessions.add(session_name)
|
|
424
|
+
|
|
407
425
|
work_dir = str(work_path.absolute())
|
|
408
426
|
script_dir = Path(__file__).parent.parent.absolute()
|
|
409
427
|
server_script = script_dir / "server" / "server.py"
|
|
410
|
-
cmd = [
|
|
428
|
+
cmd = ["uv", "run", "--project", str(script_dir), "python3", str(server_script), session_name]
|
|
429
|
+
if self._poller.get_bypass_enabled():
|
|
430
|
+
cmd += ["--", "--dangerously-skip-permissions", "--permission-mode=dontAsk"]
|
|
411
431
|
|
|
412
432
|
try:
|
|
413
|
-
import os as _os
|
|
414
433
|
env = _os.environ.copy()
|
|
415
434
|
env.pop("CLAUDECODE", None)
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
435
|
+
|
|
436
|
+
log_path = USER_DATA_DIR / "startup.log"
|
|
437
|
+
start_time = _datetime.now()
|
|
438
|
+
|
|
439
|
+
with open(log_path, 'a') as stderr_fd:
|
|
440
|
+
proc = subprocess.Popen(
|
|
441
|
+
cmd, stdout=subprocess.DEVNULL, stderr=stderr_fd,
|
|
442
|
+
start_new_session=True, cwd=work_dir, env=env,
|
|
443
|
+
)
|
|
420
444
|
|
|
421
445
|
socket_path = get_socket_path(session_name)
|
|
422
|
-
for
|
|
446
|
+
for i in range(120):
|
|
423
447
|
await asyncio.sleep(0.1)
|
|
424
448
|
if socket_path.exists():
|
|
425
449
|
break
|
|
450
|
+
if (i + 1) % 10 == 0:
|
|
451
|
+
elapsed = (i + 1) // 10
|
|
452
|
+
rc = proc.poll()
|
|
453
|
+
if rc is not None:
|
|
454
|
+
log_content = _read_log_since(start_time, log_path)
|
|
455
|
+
logger.warning(f"启动并创建群聊失败: server 进程已退出 (exitcode={rc}, elapsed={elapsed}s)\n{log_content}")
|
|
456
|
+
await card_service.send_text(chat_id, f"错误: Server 进程意外退出 (code={rc})\n\n{log_content}")
|
|
457
|
+
return
|
|
426
458
|
else:
|
|
427
|
-
|
|
459
|
+
log_content = _read_log_since(start_time, log_path)
|
|
460
|
+
logger.error(f"启动并创建群聊超时 (12s), session={session_name}\n{log_content}")
|
|
461
|
+
await card_service.send_text(chat_id, f"错误: 会话启动超时 (12s)\n\n{log_content}")
|
|
428
462
|
return
|
|
429
463
|
|
|
430
464
|
await self._cmd_new_group(user_id, chat_id, session_name)
|
|
@@ -432,6 +466,8 @@ class LarkHandler:
|
|
|
432
466
|
except Exception as e:
|
|
433
467
|
logger.error(f"启动并创建群聊失败: {e}")
|
|
434
468
|
await card_service.send_text(chat_id, f"操作失败:{e}")
|
|
469
|
+
finally:
|
|
470
|
+
self._starting_sessions.discard(session_name)
|
|
435
471
|
|
|
436
472
|
async def _cmd_kill(self, user_id: str, chat_id: str, args: str,
|
|
437
473
|
message_id: Optional[str] = None):
|
|
@@ -607,7 +643,8 @@ class LarkHandler:
|
|
|
607
643
|
}
|
|
608
644
|
card = build_menu_card(sessions, current_session=current, session_groups=session_groups, page=page,
|
|
609
645
|
notify_enabled=self._poller.get_notify_enabled(),
|
|
610
|
-
urgent_enabled=self._poller.get_urgent_enabled()
|
|
646
|
+
urgent_enabled=self._poller.get_urgent_enabled(),
|
|
647
|
+
bypass_enabled=self._poller.get_bypass_enabled())
|
|
611
648
|
await self._send_or_update_card(chat_id, card, message_id)
|
|
612
649
|
|
|
613
650
|
async def _cmd_toggle_notify(self, user_id: str, chat_id: str,
|
|
@@ -624,6 +661,13 @@ class LarkHandler:
|
|
|
624
661
|
self._poller.set_urgent_enabled(new_value)
|
|
625
662
|
await self._cmd_menu(user_id, chat_id, message_id=message_id)
|
|
626
663
|
|
|
664
|
+
async def _cmd_toggle_bypass(self, user_id: str, chat_id: str,
|
|
665
|
+
message_id: Optional[str] = None):
|
|
666
|
+
"""切换新会话 bypass 开关并刷新菜单卡片"""
|
|
667
|
+
new_value = not self._poller.get_bypass_enabled()
|
|
668
|
+
self._poller.set_bypass_enabled(new_value)
|
|
669
|
+
await self._cmd_menu(user_id, chat_id, message_id=message_id)
|
|
670
|
+
|
|
627
671
|
async def _cmd_ls(self, user_id: str, chat_id: str, args: str,
|
|
628
672
|
tree: bool = False, message_id: Optional[str] = None, page: int = 0):
|
|
629
673
|
"""查看目录文件结构"""
|
|
@@ -779,6 +823,32 @@ class LarkHandler:
|
|
|
779
823
|
except Exception as e:
|
|
780
824
|
return False, str(e)
|
|
781
825
|
|
|
826
|
+
async def _disband_groups_for_session(self, session_name: str, source: str = ""):
|
|
827
|
+
"""解散绑定到指定会话的所有专属群聊"""
|
|
828
|
+
disbanded = []
|
|
829
|
+
for cid in list(self._group_chat_ids):
|
|
830
|
+
if self._chat_bindings.get(cid) == session_name:
|
|
831
|
+
log_prefix = f"[{source}] " if source else ""
|
|
832
|
+
logger.info(f"{log_prefix}自动解散群聊: chat_id={cid[:8]}..., session={session_name}")
|
|
833
|
+
# 先清理本地状态(防止并发协程重入时重复处理)
|
|
834
|
+
self._group_chat_ids.discard(cid)
|
|
835
|
+
self._chat_bindings.pop(cid, None)
|
|
836
|
+
disbanded.append(cid)
|
|
837
|
+
# 停止轮询 + 断开 bridge
|
|
838
|
+
self._poller.stop(cid)
|
|
839
|
+
bridge = self._bridges.pop(cid, None)
|
|
840
|
+
if bridge:
|
|
841
|
+
await bridge.disconnect()
|
|
842
|
+
self._chat_sessions.pop(cid, None)
|
|
843
|
+
self._detached_slices.pop(cid, None)
|
|
844
|
+
# 调用飞书 API 解散
|
|
845
|
+
ok, err = await self._disband_group_via_api(cid)
|
|
846
|
+
if not ok:
|
|
847
|
+
logger.warning(f"{log_prefix}解散群 {cid[:8]}... API 失败: {err}")
|
|
848
|
+
if disbanded:
|
|
849
|
+
self._save_chat_bindings()
|
|
850
|
+
self._save_group_chat_ids()
|
|
851
|
+
|
|
782
852
|
async def _cmd_disband_group(self, user_id: str, chat_id: str, session_name: str,
|
|
783
853
|
message_id: Optional[str] = None):
|
|
784
854
|
"""解散与指定会话绑定的专属群聊"""
|
|
@@ -830,6 +900,8 @@ class LarkHandler:
|
|
|
830
900
|
await card_service.send_text(
|
|
831
901
|
chat_id, f"会话 '{saved_session}' 已不存在,请重新 /attach"
|
|
832
902
|
)
|
|
903
|
+
# 会话已不存在,解散绑定到该会话的所有专属群聊
|
|
904
|
+
await self._disband_groups_for_session(saved_session, source="lazy")
|
|
833
905
|
return
|
|
834
906
|
bridge = self._bridges.get(chat_id)
|
|
835
907
|
else:
|
|
@@ -849,12 +921,11 @@ class LarkHandler:
|
|
|
849
921
|
|
|
850
922
|
# ── 选项处理 ─────────────────────────────────────────────────────────────
|
|
851
923
|
|
|
852
|
-
async def handle_option_select(self, user_id: str, chat_id: str, option_value: str, option_total: int = 0):
|
|
853
|
-
"""
|
|
924
|
+
async def handle_option_select(self, user_id: str, chat_id: str, option_value: str, option_total: int = 0, *, needs_input: bool = False):
|
|
925
|
+
"""闭环选项选择:箭头键导航 + 共享内存验证
|
|
854
926
|
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
最后发 Enter 确认。
|
|
927
|
+
发箭头键导航到目标选项,每步从共享内存读取 selected_value 确认是否到位,
|
|
928
|
+
到位后发 Enter 确认。避免数字键在溢出选项上无效的问题。
|
|
858
929
|
"""
|
|
859
930
|
logger.info(f"处理选项选择: user={user_id[:8]}..., option={option_value}, total={option_total}")
|
|
860
931
|
_track_stats('lark', 'option_select',
|
|
@@ -866,37 +937,99 @@ class LarkHandler:
|
|
|
866
937
|
await card_service.send_text(chat_id, "未连接到任何会话,请先使用 /attach <会话名> 连接")
|
|
867
938
|
return
|
|
868
939
|
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
"no": "n",
|
|
872
|
-
"allow_once": "y",
|
|
873
|
-
"allow_always": "a",
|
|
874
|
-
"deny": "n",
|
|
875
|
-
}
|
|
876
|
-
key_to_send = key_mapping.get(option_value.lower())
|
|
877
|
-
|
|
878
|
-
if key_to_send:
|
|
879
|
-
# 固定映射的选项(permission 类型)
|
|
880
|
-
logger.info(f"发送按键到 Claude: {key_to_send}")
|
|
881
|
-
success = await bridge.send_key(key_to_send)
|
|
882
|
-
elif option_total > 1 and option_value == str(option_total):
|
|
883
|
-
# 最后一个选项:发 (N-1) 次 ↓ → Enter
|
|
884
|
-
import asyncio
|
|
885
|
-
steps = option_total - 1
|
|
886
|
-
logger.info(f"最后一个选项,发送: {steps}次↓ → Enter")
|
|
887
|
-
for _ in range(steps):
|
|
888
|
-
await bridge.send_raw(b"\x1b[B") # ↓ 箭头
|
|
889
|
-
await asyncio.sleep(0.05)
|
|
890
|
-
success = await bridge.send_raw(b"\r")
|
|
891
|
-
else:
|
|
892
|
-
# 普通数字选项
|
|
893
|
-
logger.info(f"发送按键到 Claude: {option_value}")
|
|
894
|
-
success = await bridge.send_key(option_value)
|
|
940
|
+
target = option_value # 目标选项 value(如 "2")
|
|
941
|
+
max_steps = max(option_total, 10) if option_total > 0 else 10
|
|
895
942
|
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
943
|
+
# 记录初始 option_block 的 block_id,防止跨选项交互误操作
|
|
944
|
+
initial_snapshot = self._poller.read_snapshot(chat_id)
|
|
945
|
+
if not initial_snapshot:
|
|
946
|
+
return
|
|
947
|
+
initial_ob = initial_snapshot.get('option_block')
|
|
948
|
+
if not initial_ob:
|
|
949
|
+
return
|
|
950
|
+
initial_block_id = initial_ob.get('block_id', '')
|
|
951
|
+
|
|
952
|
+
for step in range(max_steps):
|
|
953
|
+
# 1. 读取当前选中项
|
|
954
|
+
snapshot = self._poller.read_snapshot(chat_id)
|
|
955
|
+
if not snapshot:
|
|
956
|
+
break
|
|
957
|
+
ob = snapshot.get('option_block')
|
|
958
|
+
if not ob:
|
|
959
|
+
break # option_block 已消失(CLI 已进入下一状态)
|
|
960
|
+
|
|
961
|
+
# 检查 block_id 一致性
|
|
962
|
+
if initial_block_id and ob.get('block_id', '') != initial_block_id:
|
|
963
|
+
logger.warning(f"option_block 已切换,中止选项选择")
|
|
964
|
+
break
|
|
965
|
+
|
|
966
|
+
current = ob.get('selected_value', '')
|
|
967
|
+
|
|
968
|
+
# 闪烁帧重试:❯ 光标字符会时隐时现,selected_value 为空时短暂重试
|
|
969
|
+
if not current:
|
|
970
|
+
for _retry in range(5): # 最多重试 5 次,共 500ms
|
|
971
|
+
await asyncio.sleep(0.1)
|
|
972
|
+
snap = self._poller.read_snapshot(chat_id)
|
|
973
|
+
if not snap:
|
|
974
|
+
break
|
|
975
|
+
retry_ob = snap.get('option_block')
|
|
976
|
+
if not retry_ob:
|
|
977
|
+
break
|
|
978
|
+
current = retry_ob.get('selected_value', '')
|
|
979
|
+
if current:
|
|
980
|
+
break
|
|
981
|
+
|
|
982
|
+
# 2. 已到位 → 发 Enter(自由输入选项只导航不发 Enter)
|
|
983
|
+
if current == target:
|
|
984
|
+
if needs_input:
|
|
985
|
+
logger.info(f"自由输入选项已到位: target={target},不发送 Enter")
|
|
986
|
+
self._poller.kick(chat_id)
|
|
987
|
+
return
|
|
988
|
+
logger.info(f"选项已到位: current={current} == target={target},发送 Enter")
|
|
989
|
+
success = await bridge.send_raw(b"\r")
|
|
990
|
+
if success:
|
|
991
|
+
self._poller.kick(chat_id)
|
|
992
|
+
else:
|
|
993
|
+
await card_service.send_text(chat_id, "发送选择失败")
|
|
994
|
+
return
|
|
995
|
+
|
|
996
|
+
# 3. 未到位 → 发箭头键
|
|
997
|
+
if current and target:
|
|
998
|
+
try:
|
|
999
|
+
if int(current) < int(target):
|
|
1000
|
+
logger.info(f"步骤{step}: current={current} < target={target},发送 ↓")
|
|
1001
|
+
await bridge.send_raw(b"\x1b[B") # ↓
|
|
1002
|
+
else:
|
|
1003
|
+
logger.info(f"步骤{step}: current={current} > target={target},发送 ↑")
|
|
1004
|
+
await bridge.send_raw(b"\x1b[A") # ↑
|
|
1005
|
+
except ValueError:
|
|
1006
|
+
logger.warning(f"步骤{step}: 无法比较 current={current!r} 和 target={target!r},发送 ↓")
|
|
1007
|
+
await bridge.send_raw(b"\x1b[B")
|
|
1008
|
+
else:
|
|
1009
|
+
# selected_value 重试后仍为空(真正的初始状态),默认向下
|
|
1010
|
+
logger.info(f"步骤{step}: selected_value 重试后仍为空,发送 ↓")
|
|
1011
|
+
await bridge.send_raw(b"\x1b[B")
|
|
1012
|
+
|
|
1013
|
+
# 4. 等待共享内存更新(轮询直到 selected_value 变为另一个非空值或超时)
|
|
1014
|
+
old_selected = current
|
|
1015
|
+
deadline = time.time() + 2.0 # 单步超时 2s
|
|
1016
|
+
while time.time() < deadline:
|
|
1017
|
+
await asyncio.sleep(0.1) # 100ms 轮询
|
|
1018
|
+
snap = self._poller.read_snapshot(chat_id)
|
|
1019
|
+
if not snap:
|
|
1020
|
+
break
|
|
1021
|
+
new_ob = snap.get('option_block')
|
|
1022
|
+
if not new_ob:
|
|
1023
|
+
break # option_block 消失,退出
|
|
1024
|
+
if initial_block_id and new_ob.get('block_id', '') != initial_block_id:
|
|
1025
|
+
break # block_id 变了,外层会处理
|
|
1026
|
+
new_selected = new_ob.get('selected_value', '')
|
|
1027
|
+
# 忽略闪烁帧:只有变为另一个非空值才视为真正变化
|
|
1028
|
+
if new_selected and new_selected != old_selected:
|
|
1029
|
+
break
|
|
1030
|
+
|
|
1031
|
+
# 超过 max_steps 仍未到位,记录警告
|
|
1032
|
+
logger.warning(f"选项选择超步数: target={target}, steps={max_steps}")
|
|
900
1033
|
|
|
901
1034
|
# ── 快捷键发送 ─────────────────────────────────────────────────────────────
|
|
902
1035
|
|
package/lark_client/main.py
CHANGED
|
@@ -8,8 +8,10 @@ Remote Claude 飞书客户端
|
|
|
8
8
|
import asyncio
|
|
9
9
|
import json
|
|
10
10
|
import logging
|
|
11
|
+
import os
|
|
11
12
|
import signal
|
|
12
13
|
import sys
|
|
14
|
+
import urllib.request
|
|
13
15
|
from pathlib import Path
|
|
14
16
|
|
|
15
17
|
|
|
@@ -183,8 +185,9 @@ def handle_card_action(event: P2CardActionTrigger) -> P2CardActionTriggerRespons
|
|
|
183
185
|
if action_type == "select_option":
|
|
184
186
|
option_value = action_value.get("value", "")
|
|
185
187
|
option_total = int(action_value.get("total", "0"))
|
|
186
|
-
|
|
187
|
-
|
|
188
|
+
needs_input = action_value.get("needs_input", False)
|
|
189
|
+
print(f"[Lark] 用户选择了选项: {option_value} (total={option_total}, needs_input={needs_input})")
|
|
190
|
+
asyncio.create_task(handler.handle_option_select(user_id, chat_id, option_value, option_total, needs_input=needs_input))
|
|
188
191
|
return None
|
|
189
192
|
|
|
190
193
|
# 列表卡片:进入会话
|
|
@@ -314,6 +317,10 @@ def handle_card_action(event: P2CardActionTrigger) -> P2CardActionTriggerRespons
|
|
|
314
317
|
asyncio.create_task(handler._cmd_toggle_urgent(user_id, chat_id, message_id=message_id))
|
|
315
318
|
return None
|
|
316
319
|
|
|
320
|
+
if action_type == "menu_toggle_bypass":
|
|
321
|
+
asyncio.create_task(handler._cmd_toggle_bypass(user_id, chat_id, message_id=message_id))
|
|
322
|
+
return None
|
|
323
|
+
|
|
317
324
|
# 各卡片底部菜单按钮:辅助卡片就地→菜单,流式卡片降级新卡
|
|
318
325
|
if action_type == "menu_open":
|
|
319
326
|
asyncio.create_task(handler._cmd_menu(user_id, chat_id, message_id=message_id))
|
|
@@ -371,6 +378,21 @@ class LarkBot:
|
|
|
371
378
|
log_level=lark.LogLevel.INFO,
|
|
372
379
|
)
|
|
373
380
|
|
|
381
|
+
# 代理兼容:检测 SOCKS 代理,按配置决定是否绕过
|
|
382
|
+
proxy_info = urllib.request.getproxies()
|
|
383
|
+
socks_proxy = (proxy_info.get('socks') or proxy_info.get('all')
|
|
384
|
+
or proxy_info.get('https') or proxy_info.get('http'))
|
|
385
|
+
if socks_proxy and 'socks' in socks_proxy.lower():
|
|
386
|
+
if config.LARK_NO_PROXY:
|
|
387
|
+
# 用户选择绕过代理 → 清除代理环境变量
|
|
388
|
+
for var in ('ALL_PROXY', 'all_proxy', 'HTTPS_PROXY', 'https_proxy',
|
|
389
|
+
'HTTP_PROXY', 'http_proxy', 'SOCKS_PROXY', 'socks_proxy'):
|
|
390
|
+
os.environ.pop(var, None)
|
|
391
|
+
print(f"检测到 SOCKS 代理 ({socks_proxy}),已按 LARK_NO_PROXY=1 绕过")
|
|
392
|
+
else:
|
|
393
|
+
print(f"检测到 SOCKS 代理 ({socks_proxy}),将通过代理连接")
|
|
394
|
+
print(" 如连接失败,可在 .env 中设置 LARK_NO_PROXY=1 绕过代理")
|
|
395
|
+
|
|
374
396
|
self.running = True
|
|
375
397
|
print("\n机器人已启动,等待消息...")
|
|
376
398
|
print("在飞书中发送 /help 查看使用说明\n")
|
|
@@ -119,22 +119,16 @@ class SessionBridge:
|
|
|
119
119
|
if not self.writer or not self.running:
|
|
120
120
|
return False
|
|
121
121
|
try:
|
|
122
|
+
# 分两步发送:先发文本,等 Ink 框架处理完毕,再发 Enter
|
|
123
|
+
# 不能合并为 text+\r 一次写入(Ink 同一 tick 处理时 \r 会被提前消费,导致需要两次 Enter)
|
|
124
|
+
# 不能在文本前/后加 ESC(ESC+Enter 被终端解释为 Alt+Enter,导致换行而非提交)
|
|
122
125
|
msg = InputMessage(text.encode('utf-8'), self.client_id)
|
|
123
126
|
self.writer.write(encode_message(msg))
|
|
124
127
|
await self.writer.drain()
|
|
125
|
-
|
|
126
|
-
# 发送 Escape 退出多行模式
|
|
127
|
-
await asyncio.sleep(0.1)
|
|
128
|
-
msg = InputMessage(b"\x1b", self.client_id)
|
|
129
|
-
self.writer.write(encode_message(msg))
|
|
130
|
-
await self.writer.drain()
|
|
131
|
-
|
|
132
|
-
# 发送 Enter 提交
|
|
133
|
-
await asyncio.sleep(0.1)
|
|
128
|
+
await asyncio.sleep(0.05)
|
|
134
129
|
msg = InputMessage(b"\r", self.client_id)
|
|
135
130
|
self.writer.write(encode_message(msg))
|
|
136
131
|
await self.writer.drain()
|
|
137
|
-
|
|
138
132
|
return True
|
|
139
133
|
except Exception as e:
|
|
140
134
|
logger.error(f"发送失败: {e}")
|
|
@@ -40,6 +40,7 @@ from utils.session import ensure_user_data_dir, USER_DATA_DIR
|
|
|
40
40
|
# ── 常量 ──────────────────────────────────────────────────────────────────────
|
|
41
41
|
INITIAL_WINDOW = 30 # 首次 attach 最多显示最近 30 个 blocks
|
|
42
42
|
from .config import MAX_CARD_BLOCKS # 单张卡片最多 N 个 blocks → 超限冻结(可通过 .env 配置)
|
|
43
|
+
CARD_SIZE_LIMIT = 25 * 1024 # 25KB,飞书限制 30KB,留 5KB 余量
|
|
43
44
|
POLL_INTERVAL = 1.0 # 轮询间隔(秒)
|
|
44
45
|
RAPID_INTERVAL = 0.2 # 快速轮询间隔(秒)
|
|
45
46
|
RAPID_DURATION = 2.0 # 快速轮询持续时间(秒)
|
|
@@ -153,6 +154,16 @@ class SharedMemoryPoller:
|
|
|
153
154
|
if exc:
|
|
154
155
|
logger.error(f"轮询 Task 异常: chat_id={chat_id[:8]}..., {exc}", exc_info=exc)
|
|
155
156
|
|
|
157
|
+
def read_snapshot(self, chat_id: str) -> Optional[dict]:
|
|
158
|
+
"""直接读取指定 chat_id 的当前共享内存快照(供 handle_option_select 等即时查询使用)"""
|
|
159
|
+
tracker = self._trackers.get(chat_id)
|
|
160
|
+
if tracker and tracker.reader:
|
|
161
|
+
try:
|
|
162
|
+
return tracker.reader.read()
|
|
163
|
+
except Exception as e:
|
|
164
|
+
logger.warning(f"read_snapshot 失败: {e}")
|
|
165
|
+
return None
|
|
166
|
+
|
|
156
167
|
def kick(self, chat_id: str) -> None:
|
|
157
168
|
"""触发立即轮询并进入快速轮询模式"""
|
|
158
169
|
self._rapid_until[chat_id] = time.time() + RAPID_DURATION
|
|
@@ -262,6 +273,16 @@ class SharedMemoryPoller:
|
|
|
262
273
|
from .card_builder import build_stream_card
|
|
263
274
|
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)
|
|
264
275
|
|
|
276
|
+
# 大小超限检查(与 blocks 数量超限同一套逻辑)
|
|
277
|
+
card_size = len(json.dumps(card_dict, ensure_ascii=False).encode('utf-8'))
|
|
278
|
+
if card_size > CARD_SIZE_LIMIT:
|
|
279
|
+
freeze_count = self._find_freeze_count(blocks_slice, tracker.session_name)
|
|
280
|
+
await self._freeze_and_split(
|
|
281
|
+
tracker, blocks, status_line, bottom_bar, agent_panel, option_block,
|
|
282
|
+
cli_type=cli_type, freeze_count=freeze_count,
|
|
283
|
+
)
|
|
284
|
+
return
|
|
285
|
+
|
|
265
286
|
active.sequence += 1
|
|
266
287
|
success = await self._card_service.update_card(
|
|
267
288
|
card_id=active.card_id,
|
|
@@ -326,6 +347,15 @@ class SharedMemoryPoller:
|
|
|
326
347
|
|
|
327
348
|
from .card_builder import build_stream_card
|
|
328
349
|
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)
|
|
350
|
+
|
|
351
|
+
# 新卡大小检查:超限则从头部裁剪
|
|
352
|
+
card_size = len(json.dumps(card_dict, ensure_ascii=False).encode('utf-8'))
|
|
353
|
+
while card_size > CARD_SIZE_LIMIT and len(blocks_slice) > 1:
|
|
354
|
+
blocks_slice = blocks_slice[1:]
|
|
355
|
+
start_idx += 1
|
|
356
|
+
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)
|
|
357
|
+
card_size = len(json.dumps(card_dict, ensure_ascii=False).encode('utf-8'))
|
|
358
|
+
|
|
329
359
|
card_id = await self._card_service.create_card(card_dict)
|
|
330
360
|
|
|
331
361
|
if card_id:
|
|
@@ -384,6 +414,24 @@ class SharedMemoryPoller:
|
|
|
384
414
|
f"[ELEMENT_LIMIT_SPLIT] session={tracker.session_name} "
|
|
385
415
|
f"new_start={new_start} blocks={len(new_blocks)} card_id={new_card_id}"
|
|
386
416
|
)
|
|
417
|
+
tracker.last_notify_message_id = None
|
|
418
|
+
|
|
419
|
+
def _find_freeze_count(self, blocks_slice: List[dict], session_name: str) -> int:
|
|
420
|
+
"""二分查找冻结卡片能容纳的最大 blocks 数(保证卡片 JSON 大小 ≤ CARD_SIZE_LIMIT)"""
|
|
421
|
+
from .card_builder import build_stream_card
|
|
422
|
+
lo, hi = 1, len(blocks_slice)
|
|
423
|
+
result = 1
|
|
424
|
+
while lo <= hi:
|
|
425
|
+
mid = (lo + hi) // 2
|
|
426
|
+
card = build_stream_card(blocks_slice[:mid], None, None,
|
|
427
|
+
is_frozen=True, session_name=session_name)
|
|
428
|
+
size = len(json.dumps(card, ensure_ascii=False).encode('utf-8'))
|
|
429
|
+
if size <= CARD_SIZE_LIMIT:
|
|
430
|
+
result = mid
|
|
431
|
+
lo = mid + 1
|
|
432
|
+
else:
|
|
433
|
+
hi = mid - 1
|
|
434
|
+
return result
|
|
387
435
|
|
|
388
436
|
async def _freeze_and_split(
|
|
389
437
|
self, tracker: StreamTracker, blocks: List[dict],
|
|
@@ -391,12 +439,15 @@ class SharedMemoryPoller:
|
|
|
391
439
|
agent_panel: Optional[dict] = None,
|
|
392
440
|
option_block: Optional[dict] = None,
|
|
393
441
|
cli_type: str = "claude",
|
|
442
|
+
freeze_count: Optional[int] = None,
|
|
394
443
|
) -> None:
|
|
395
444
|
"""冻结当前卡片 + 开新卡"""
|
|
396
445
|
active = tracker.cards[-1]
|
|
446
|
+
count = freeze_count if freeze_count is not None else MAX_CARD_BLOCKS
|
|
447
|
+
reason = 'size' if freeze_count is not None else 'count'
|
|
397
448
|
|
|
398
|
-
# 冻结当前卡片(只保留前
|
|
399
|
-
frozen_blocks = blocks[active.start_idx:active.start_idx +
|
|
449
|
+
# 冻结当前卡片(只保留前 count 个 blocks,移除状态区和按钮)
|
|
450
|
+
frozen_blocks = blocks[active.start_idx:active.start_idx + count]
|
|
400
451
|
from .card_builder import build_stream_card
|
|
401
452
|
frozen_card = build_stream_card(frozen_blocks, None, None, is_frozen=True)
|
|
402
453
|
active.sequence += 1
|
|
@@ -406,16 +457,25 @@ class SharedMemoryPoller:
|
|
|
406
457
|
chat_id=tracker.chat_id)
|
|
407
458
|
logger.info(
|
|
408
459
|
f"[FREEZE] session={tracker.session_name} card_id={active.card_id} "
|
|
409
|
-
f"blocks=[{active.start_idx}:{active.start_idx +
|
|
460
|
+
f"blocks=[{active.start_idx}:{active.start_idx + count}] reason={reason}"
|
|
410
461
|
)
|
|
411
462
|
|
|
412
463
|
# 创建新卡片
|
|
413
|
-
new_start = active.start_idx +
|
|
464
|
+
new_start = active.start_idx + count
|
|
414
465
|
new_blocks = blocks[new_start:]
|
|
415
466
|
if not new_blocks:
|
|
416
467
|
return
|
|
417
468
|
|
|
418
469
|
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)
|
|
470
|
+
|
|
471
|
+
# 新卡大小检查:超限则从头部裁剪
|
|
472
|
+
new_card_size = len(json.dumps(new_card_dict, ensure_ascii=False).encode('utf-8'))
|
|
473
|
+
while new_card_size > CARD_SIZE_LIMIT and len(new_blocks) > 1:
|
|
474
|
+
new_blocks = new_blocks[1:]
|
|
475
|
+
new_start += 1
|
|
476
|
+
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)
|
|
477
|
+
new_card_size = len(json.dumps(new_card_dict, ensure_ascii=False).encode('utf-8'))
|
|
478
|
+
|
|
419
479
|
new_card_id = await self._card_service.create_card(new_card_dict)
|
|
420
480
|
if new_card_id:
|
|
421
481
|
await self._card_service.send_card(tracker.chat_id, new_card_id)
|
|
@@ -425,6 +485,7 @@ class SharedMemoryPoller:
|
|
|
425
485
|
f"[NEW after FREEZE] session={tracker.session_name} start_idx={new_start} "
|
|
426
486
|
f"blocks={len(new_blocks)} card_id={new_card_id}"
|
|
427
487
|
)
|
|
488
|
+
tracker.last_notify_message_id = None
|
|
428
489
|
|
|
429
490
|
async def _check_ready_notification(
|
|
430
491
|
self, tracker: StreamTracker,
|
|
@@ -504,6 +565,17 @@ class SharedMemoryPoller:
|
|
|
504
565
|
_save_urgent_enabled(enabled)
|
|
505
566
|
logger.info(f"加急通知开关已{'开启' if enabled else '关闭'}")
|
|
506
567
|
|
|
568
|
+
def get_bypass_enabled(self) -> bool:
|
|
569
|
+
"""获取新会话 bypass 开关状态"""
|
|
570
|
+
return _bypass_enabled
|
|
571
|
+
|
|
572
|
+
def set_bypass_enabled(self, enabled: bool) -> None:
|
|
573
|
+
"""更新新会话 bypass 开关状态并持久化"""
|
|
574
|
+
global _bypass_enabled
|
|
575
|
+
_bypass_enabled = enabled
|
|
576
|
+
_save_bypass_enabled(enabled)
|
|
577
|
+
logger.info(f"新会话 bypass 开关已{'开启' if enabled else '关闭'}")
|
|
578
|
+
|
|
507
579
|
@staticmethod
|
|
508
580
|
def _compute_hash(
|
|
509
581
|
blocks: list, status_line: Optional[dict],
|
|
@@ -534,6 +606,7 @@ def _is_ready(blocks: list, status_line: Optional[dict], option_block: Optional[
|
|
|
534
606
|
_READY_COUNT_FILE = USER_DATA_DIR / "ready_notify_count"
|
|
535
607
|
_NOTIFY_ENABLED_FILE = USER_DATA_DIR / "ready_notify_enabled"
|
|
536
608
|
_URGENT_ENABLED_FILE = USER_DATA_DIR / "urgent_notify_enabled"
|
|
609
|
+
_BYPASS_ENABLED_FILE = USER_DATA_DIR / "bypass_enabled"
|
|
537
610
|
|
|
538
611
|
|
|
539
612
|
def _load_notify_enabled() -> bool:
|
|
@@ -570,9 +643,27 @@ def _save_urgent_enabled(enabled: bool) -> None:
|
|
|
570
643
|
logger.warning(f"_save_urgent_enabled 失败: {e}")
|
|
571
644
|
|
|
572
645
|
|
|
646
|
+
def _load_bypass_enabled() -> bool:
|
|
647
|
+
"""读取新会话 bypass 开关状态,不存在或解析失败返回 False(默认关闭)"""
|
|
648
|
+
try:
|
|
649
|
+
return _BYPASS_ENABLED_FILE.read_text().strip() == "1"
|
|
650
|
+
except Exception:
|
|
651
|
+
return False
|
|
652
|
+
|
|
653
|
+
|
|
654
|
+
def _save_bypass_enabled(enabled: bool) -> None:
|
|
655
|
+
"""持久化新会话 bypass 开关状态"""
|
|
656
|
+
try:
|
|
657
|
+
ensure_user_data_dir()
|
|
658
|
+
_BYPASS_ENABLED_FILE.write_text("1" if enabled else "0")
|
|
659
|
+
except Exception as e:
|
|
660
|
+
logger.warning(f"_save_bypass_enabled 失败: {e}")
|
|
661
|
+
|
|
662
|
+
|
|
573
663
|
# 模块级开关状态:启动时加载一次
|
|
574
664
|
_notify_enabled: bool = _load_notify_enabled()
|
|
575
665
|
_urgent_enabled: bool = _load_urgent_enabled()
|
|
666
|
+
_bypass_enabled: bool = _load_bypass_enabled()
|
|
576
667
|
|
|
577
668
|
|
|
578
669
|
def _increment_ready_count() -> int:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "remote-claude",
|
|
3
|
-
"version": "1.0.
|
|
3
|
+
"version": "1.0.3",
|
|
4
4
|
"description": "双端共享 Claude CLI 工具",
|
|
5
5
|
"bin": {
|
|
6
6
|
"remote-claude": "bin/remote-claude",
|
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"cl": "bin/cl"
|
|
9
9
|
},
|
|
10
10
|
"scripts": {
|
|
11
|
+
"preinstall": "bash scripts/preinstall.sh",
|
|
11
12
|
"postinstall": "bash scripts/postinstall.sh"
|
|
12
13
|
},
|
|
13
14
|
"files": [
|
package/pyproject.toml
CHANGED
|
@@ -258,6 +258,40 @@ def _get_row_text(screen: pyte.Screen, row: int) -> str:
|
|
|
258
258
|
return ''.join(buf).rstrip()
|
|
259
259
|
|
|
260
260
|
|
|
261
|
+
def _is_dim_fg(fg_color: str) -> bool:
|
|
262
|
+
"""判断前景色是否为灰色/暗色(placeholder 风格)。"""
|
|
263
|
+
if not fg_color or fg_color == 'default':
|
|
264
|
+
return False
|
|
265
|
+
name = fg_color.lower().replace(' ', '').replace('-', '')
|
|
266
|
+
if name in ('brightblack', 'black'): # bright_black = gray
|
|
267
|
+
return True
|
|
268
|
+
if len(fg_color) == 6:
|
|
269
|
+
try:
|
|
270
|
+
r, g, b = int(fg_color[0:2], 16), int(fg_color[2:4], 16), int(fg_color[4:6], 16)
|
|
271
|
+
L = 0.2126 * r + 0.7152 * g + 0.0722 * b
|
|
272
|
+
# 灰色调(R≈G≈B)且非纯白 → inactive/placeholder 颜色(如 999999 亮度 153 > 128)
|
|
273
|
+
if max(r, g, b) - min(r, g, b) <= 30 and L < 230:
|
|
274
|
+
return True
|
|
275
|
+
# 非灰色但较暗
|
|
276
|
+
return L < 128
|
|
277
|
+
except ValueError:
|
|
278
|
+
pass
|
|
279
|
+
return False
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def _option_label_is_dim(screen: pyte.Screen, row: int) -> bool:
|
|
283
|
+
"""检查选项行 label 部分前景色是否为暗色(自由输入 placeholder 风格)。"""
|
|
284
|
+
text = _get_row_text(screen, row)
|
|
285
|
+
m = re.match(r'^[\s❯]*\d+[.)]\s+', text)
|
|
286
|
+
if not m:
|
|
287
|
+
return False
|
|
288
|
+
col = m.end()
|
|
289
|
+
buf_row = screen.buffer[row]
|
|
290
|
+
if col not in buf_row or not buf_row[col].data.strip():
|
|
291
|
+
return False
|
|
292
|
+
return _is_dim_fg(buf_row[col].fg)
|
|
293
|
+
|
|
294
|
+
|
|
261
295
|
def _get_col0(screen: pyte.Screen, row: int) -> str:
|
|
262
296
|
"""获取指定行第一列字符(col=0)"""
|
|
263
297
|
try:
|
|
@@ -874,6 +908,7 @@ class ClaudeParser(BaseParser):
|
|
|
874
908
|
|
|
875
909
|
options: List[dict] = []
|
|
876
910
|
current_opt: Optional[dict] = None
|
|
911
|
+
selected_value = ""
|
|
877
912
|
ansi_raw_lines = [_get_row_ansi_text(screen, r) for r in input_rows + overflow]
|
|
878
913
|
|
|
879
914
|
for row in all_option_rows:
|
|
@@ -891,7 +926,10 @@ class ClaudeParser(BaseParser):
|
|
|
891
926
|
'label': m.group(2).strip(),
|
|
892
927
|
'value': m.group(1),
|
|
893
928
|
'description': '',
|
|
929
|
+
'needs_input': _option_label_is_dim(screen, row), # 自由输入选项检测
|
|
894
930
|
}
|
|
931
|
+
if line.startswith('❯'):
|
|
932
|
+
selected_value = m.group(1)
|
|
895
933
|
elif current_opt is not None and line:
|
|
896
934
|
# 描述行
|
|
897
935
|
current_opt['description'] = (
|
|
@@ -905,6 +943,7 @@ class ClaudeParser(BaseParser):
|
|
|
905
943
|
return OptionBlock(
|
|
906
944
|
sub_type='option', tag=tag, question=question, options=options,
|
|
907
945
|
ansi_raw='\n'.join(ansi_raw_lines).rstrip(),
|
|
946
|
+
selected_value=selected_value,
|
|
908
947
|
)
|
|
909
948
|
return None
|
|
910
949
|
|
|
@@ -1020,6 +1059,7 @@ class ClaudeParser(BaseParser):
|
|
|
1020
1059
|
content_lines = pre_option_contents[1:-1]
|
|
1021
1060
|
|
|
1022
1061
|
# 只收集范围内的 options
|
|
1062
|
+
selected_value = ""
|
|
1023
1063
|
for i in range(first_opt_idx, last_opt_idx + 1):
|
|
1024
1064
|
line, cat = classified[i]
|
|
1025
1065
|
if cat == 'option':
|
|
@@ -1029,6 +1069,8 @@ class ClaudeParser(BaseParser):
|
|
|
1029
1069
|
'label': m.group(2).strip(),
|
|
1030
1070
|
'value': m.group(1),
|
|
1031
1071
|
})
|
|
1072
|
+
if line.startswith('❯'):
|
|
1073
|
+
selected_value = m.group(1)
|
|
1032
1074
|
|
|
1033
1075
|
return OptionBlock(
|
|
1034
1076
|
sub_type='permission',
|
|
@@ -1037,6 +1079,7 @@ class ClaudeParser(BaseParser):
|
|
|
1037
1079
|
question=question,
|
|
1038
1080
|
options=options,
|
|
1039
1081
|
ansi_raw='\n'.join(ansi_lines).rstrip(),
|
|
1082
|
+
selected_value=selected_value,
|
|
1040
1083
|
)
|
|
1041
1084
|
|
|
1042
1085
|
def _parse_agent_list_panel(
|
|
@@ -1256,6 +1256,7 @@ class CodexParser(BaseParser):
|
|
|
1256
1256
|
|
|
1257
1257
|
options: List[dict] = []
|
|
1258
1258
|
current_opt: Optional[dict] = None
|
|
1259
|
+
selected_value = ""
|
|
1259
1260
|
ansi_raw_lines = [_get_row_ansi_text(screen, r) for r in input_rows + overflow]
|
|
1260
1261
|
|
|
1261
1262
|
for row in all_option_rows:
|
|
@@ -1274,6 +1275,8 @@ class CodexParser(BaseParser):
|
|
|
1274
1275
|
'value': m.group(1),
|
|
1275
1276
|
'description': '',
|
|
1276
1277
|
}
|
|
1278
|
+
if line[0:1] in CODEX_PROMPT_CHARS or line.startswith('❯'):
|
|
1279
|
+
selected_value = m.group(1)
|
|
1277
1280
|
elif current_opt is not None and line:
|
|
1278
1281
|
# 描述行
|
|
1279
1282
|
current_opt['description'] = (
|
|
@@ -1287,6 +1290,7 @@ class CodexParser(BaseParser):
|
|
|
1287
1290
|
return OptionBlock(
|
|
1288
1291
|
sub_type='option', tag=tag, question=question, options=options,
|
|
1289
1292
|
ansi_raw='\n'.join(ansi_raw_lines).rstrip(),
|
|
1293
|
+
selected_value=selected_value,
|
|
1290
1294
|
)
|
|
1291
1295
|
return None
|
|
1292
1296
|
|
|
@@ -1407,6 +1411,7 @@ class CodexParser(BaseParser):
|
|
|
1407
1411
|
content_lines = pre_option_contents[1:-1]
|
|
1408
1412
|
|
|
1409
1413
|
# 只收集范围内的 options
|
|
1414
|
+
selected_value = ""
|
|
1410
1415
|
for i in range(first_opt_idx, last_opt_idx + 1):
|
|
1411
1416
|
line, cat = classified[i]
|
|
1412
1417
|
if cat == 'option':
|
|
@@ -1416,6 +1421,8 @@ class CodexParser(BaseParser):
|
|
|
1416
1421
|
'label': m.group(2).strip(),
|
|
1417
1422
|
'value': m.group(1),
|
|
1418
1423
|
})
|
|
1424
|
+
if line[0:1] in CODEX_PROMPT_CHARS or line.startswith('❯'):
|
|
1425
|
+
selected_value = m.group(1)
|
|
1419
1426
|
|
|
1420
1427
|
return OptionBlock(
|
|
1421
1428
|
sub_type='permission',
|
|
@@ -1424,6 +1431,7 @@ class CodexParser(BaseParser):
|
|
|
1424
1431
|
question=question,
|
|
1425
1432
|
options=options,
|
|
1426
1433
|
ansi_raw='\n'.join(ansi_lines).rstrip(),
|
|
1434
|
+
selected_value=selected_value,
|
|
1427
1435
|
)
|
|
1428
1436
|
|
|
1429
1437
|
def _parse_agent_list_panel(
|