remote-claude 1.0.2 → 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 CHANGED
@@ -30,3 +30,7 @@ ALLOWED_USERS=ou_xxxxx,ou_yyyyy
30
30
  # 支持: DEBUG / INFO / WARNING / ERROR
31
31
  # SERVER_LOG_LEVEL=INFO
32
32
 
33
+ # SOCKS 代理兼容(可选)
34
+ # 系统有 SOCKS 代理但飞书可直连时,设为 1 绕过代理
35
+ # LARK_NO_PROXY=1
36
+
@@ -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 (session_groups and auto_session in session_groups) else "创建群聊"},
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={session_groups[auto_session]}",
1031
- "android_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[auto_session]}",
1032
- "ios_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[auto_session]}",
1033
- "pc_url": f"https://applink.feishu.cn/client/chat/open?openChatId={session_groups[auto_session]}"}]
1034
- if (session_groups and auto_session in session_groups) else
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,
@@ -48,3 +48,7 @@ LARK_LOG_LEVEL = {
48
48
  "WARNING": 30,
49
49
  "ERROR": 40,
50
50
  }.get(_LARK_LOG_LEVEL, 20) # 默认 INFO
51
+
52
+ # SOCKS 代理兼容(可选,默认 False)
53
+ # 系统有 SOCKS 代理但飞书可直连时,设为 1 绕过代理
54
+ LARK_NO_PROXY = os.getenv("LARK_NO_PROXY", "").strip() in ("1", "true", "yes")
@@ -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,9 +340,14 @@ 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 = [sys.executable, str(server_script), session_name]
350
+ cmd = ["uv", "run", "--project", str(script_dir), "python3", str(server_script), session_name]
322
351
  if self._poller.get_bypass_enabled():
323
352
  cmd += ["--", "--dangerously-skip-permissions", "--permission-mode=dontAsk"]
324
353
 
@@ -326,37 +355,21 @@ class LarkHandler:
326
355
  _track_stats('lark', 'cmd_start', session_name=session_name, chat_id=chat_id)
327
356
 
328
357
  try:
329
- import os as _os
330
- from datetime import datetime as _datetime
331
358
  env = _os.environ.copy()
332
359
  env.pop("CLAUDECODE", None)
333
360
 
334
- from utils.session import USER_DATA_DIR
335
361
  log_path = USER_DATA_DIR / "startup.log"
336
362
  start_time = _datetime.now()
337
363
 
338
- proc = subprocess.Popen(
339
- cmd,
340
- stdout=subprocess.DEVNULL,
341
- stderr=subprocess.DEVNULL,
342
- start_new_session=True,
343
- cwd=work_dir,
344
- env=env,
345
- )
346
-
347
- def _read_log_since(since):
348
- if not log_path.exists():
349
- return ""
350
- lines = []
351
- for line in log_path.read_text(encoding="utf-8").splitlines():
352
- try:
353
- ts = _datetime.strptime(line[:23], "%Y-%m-%d %H:%M:%S.%f")
354
- if ts >= since:
355
- lines.append(line)
356
- except ValueError:
357
- if lines:
358
- lines.append(line)
359
- 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
+ )
360
373
 
361
374
  socket_path = get_socket_path(session_name)
362
375
  for i in range(120):
@@ -367,13 +380,13 @@ class LarkHandler:
367
380
  elapsed = (i + 1) // 10
368
381
  rc = proc.poll()
369
382
  if rc is not None:
370
- log_content = _read_log_since(start_time)
383
+ log_content = _read_log_since(start_time, log_path)
371
384
  logger.warning(f"会话启动失败: server 进程已退出 (exitcode={rc}, elapsed={elapsed}s)\n{log_content}")
372
385
  await card_service.send_text(chat_id, f"错误: Server 进程意外退出 (code={rc})\n\n{log_content}")
373
386
  return
374
387
  logger.info(f"等待 server socket... ({elapsed}s)")
375
388
  else:
376
- log_content = _read_log_since(start_time)
389
+ log_content = _read_log_since(start_time, log_path)
377
390
  logger.error(f"会话启动超时 (12s), session={session_name}\n{log_content}")
378
391
  await card_service.send_text(chat_id, f"错误: 会话启动超时 (12s)\n\n{log_content}")
379
392
  return
@@ -391,44 +404,61 @@ class LarkHandler:
391
404
  except Exception as e:
392
405
  logger.error(f"启动会话失败: {e}")
393
406
  await card_service.send_text(chat_id, f"错误: 启动失败 - {e}")
407
+ finally:
408
+ self._starting_sessions.discard(session_name)
394
409
 
395
410
  async def _cmd_start_and_new_group(self, user_id: str, chat_id: str,
396
411
  session_name: str, path: str):
397
412
  """在指定目录启动会话并创建专属群聊"""
398
- sessions = list_active_sessions()
399
- if any(s["name"] == session_name for s in sessions):
400
- # 会话已存在,直接创建群聊
401
- await self._cmd_new_group(user_id, chat_id, session_name)
402
- return
403
-
404
413
  work_path = Path(path).expanduser()
405
414
  if not work_path.is_dir():
406
415
  await card_service.send_text(chat_id, f"错误: 路径无效: {path}")
407
416
  return
408
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
+
409
425
  work_dir = str(work_path.absolute())
410
426
  script_dir = Path(__file__).parent.parent.absolute()
411
427
  server_script = script_dir / "server" / "server.py"
412
- cmd = [sys.executable, str(server_script), session_name]
428
+ cmd = ["uv", "run", "--project", str(script_dir), "python3", str(server_script), session_name]
413
429
  if self._poller.get_bypass_enabled():
414
430
  cmd += ["--", "--dangerously-skip-permissions", "--permission-mode=dontAsk"]
415
431
 
416
432
  try:
417
- import os as _os
418
433
  env = _os.environ.copy()
419
434
  env.pop("CLAUDECODE", None)
420
- subprocess.Popen(
421
- cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
422
- start_new_session=True, cwd=work_dir, env=env,
423
- )
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
+ )
424
444
 
425
445
  socket_path = get_socket_path(session_name)
426
- for _ in range(120):
446
+ for i in range(120):
427
447
  await asyncio.sleep(0.1)
428
448
  if socket_path.exists():
429
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
430
458
  else:
431
- await card_service.send_text(chat_id, "错误: 会话启动超时")
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}")
432
462
  return
433
463
 
434
464
  await self._cmd_new_group(user_id, chat_id, session_name)
@@ -436,6 +466,8 @@ class LarkHandler:
436
466
  except Exception as e:
437
467
  logger.error(f"启动并创建群聊失败: {e}")
438
468
  await card_service.send_text(chat_id, f"操作失败:{e}")
469
+ finally:
470
+ self._starting_sessions.discard(session_name)
439
471
 
440
472
  async def _cmd_kill(self, user_id: str, chat_id: str, args: str,
441
473
  message_id: Optional[str] = None):
@@ -791,6 +823,32 @@ class LarkHandler:
791
823
  except Exception as e:
792
824
  return False, str(e)
793
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
+
794
852
  async def _cmd_disband_group(self, user_id: str, chat_id: str, session_name: str,
795
853
  message_id: Optional[str] = None):
796
854
  """解散与指定会话绑定的专属群聊"""
@@ -842,6 +900,8 @@ class LarkHandler:
842
900
  await card_service.send_text(
843
901
  chat_id, f"会话 '{saved_session}' 已不存在,请重新 /attach"
844
902
  )
903
+ # 会话已不存在,解散绑定到该会话的所有专属群聊
904
+ await self._disband_groups_for_session(saved_session, source="lazy")
845
905
  return
846
906
  bridge = self._bridges.get(chat_id)
847
907
  else:
@@ -861,12 +921,11 @@ class LarkHandler:
861
921
 
862
922
  # ── 选项处理 ─────────────────────────────────────────────────────────────
863
923
 
864
- async def handle_option_select(self, user_id: str, chat_id: str, option_value: str, option_total: int = 0):
865
- """处理用户选择的选项(按钮点击)
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
+ """闭环选项选择:箭头键导航 + 共享内存验证
866
926
 
867
- 最后一个选项特殊处理:Claude CLI 的光标选择模式中,最后一个选项
868
- 直接发数字键无效。改为先发倒数第二项的数字跳转,再发 移到最后一项,
869
- 最后发 Enter 确认。
927
+ 发箭头键导航到目标选项,每步从共享内存读取 selected_value 确认是否到位,
928
+ 到位后发 Enter 确认。避免数字键在溢出选项上无效的问题。
870
929
  """
871
930
  logger.info(f"处理选项选择: user={user_id[:8]}..., option={option_value}, total={option_total}")
872
931
  _track_stats('lark', 'option_select',
@@ -878,37 +937,99 @@ class LarkHandler:
878
937
  await card_service.send_text(chat_id, "未连接到任何会话,请先使用 /attach <会话名> 连接")
879
938
  return
880
939
 
881
- key_mapping = {
882
- "yes": "y",
883
- "no": "n",
884
- "allow_once": "y",
885
- "allow_always": "a",
886
- "deny": "n",
887
- }
888
- key_to_send = key_mapping.get(option_value.lower())
889
-
890
- if key_to_send:
891
- # 固定映射的选项(permission 类型)
892
- logger.info(f"发送按键到 Claude: {key_to_send}")
893
- success = await bridge.send_key(key_to_send)
894
- elif option_total > 1 and option_value == str(option_total):
895
- # 最后一个选项:发 (N-1) 次 ↓ → Enter
896
- import asyncio
897
- steps = option_total - 1
898
- logger.info(f"最后一个选项,发送: {steps}次↓ → Enter")
899
- for _ in range(steps):
900
- await bridge.send_raw(b"\x1b[B") # ↓ 箭头
901
- await asyncio.sleep(0.05)
902
- success = await bridge.send_raw(b"\r")
903
- else:
904
- # 普通数字选项
905
- logger.info(f"发送按键到 Claude: {option_value}")
906
- 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
907
942
 
908
- if success:
909
- self._poller.kick(chat_id)
910
- else:
911
- await card_service.send_text(chat_id, "发送选择失败")
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}")
912
1033
 
913
1034
  # ── 快捷键发送 ─────────────────────────────────────────────────────────────
914
1035
 
@@ -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
- print(f"[Lark] 用户选择了选项: {option_value} (total={option_total})")
187
- asyncio.create_task(handler.handle_option_select(user_id, chat_id, option_value, option_total))
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
  # 列表卡片:进入会话
@@ -375,6 +378,21 @@ class LarkBot:
375
378
  log_level=lark.LogLevel.INFO,
376
379
  )
377
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
+
378
396
  self.running = True
379
397
  print("\n机器人已启动,等待消息...")
380
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
- # 冻结当前卡片(只保留前 MAX_CARD_BLOCKS 个 blocks,移除状态区和按钮)
399
- frozen_blocks = blocks[active.start_idx:active.start_idx + MAX_CARD_BLOCKS]
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 + MAX_CARD_BLOCKS}]"
460
+ f"blocks=[{active.start_idx}:{active.start_idx + count}] reason={reason}"
410
461
  )
411
462
 
412
463
  # 创建新卡片
413
- new_start = active.start_idx + MAX_CARD_BLOCKS
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,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "remote-claude",
3
- "version": "1.0.2",
3
+ "version": "1.0.3",
4
4
  "description": "双端共享 Claude CLI 工具",
5
5
  "bin": {
6
6
  "remote-claude": "bin/remote-claude",
package/pyproject.toml CHANGED
@@ -8,6 +8,7 @@ dependencies = [
8
8
  "python-dotenv>=1.0.0",
9
9
  "pyte>=0.8.0",
10
10
  "mixpanel>=5.0.0",
11
+ "python-socks[asyncio]>=2.0.0",
11
12
  ]
12
13
 
13
14
  [project.scripts]
@@ -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(
@@ -79,6 +79,7 @@ class OptionBlock:
79
79
  ansi_raw: str = "" # 整个选项区域的 ANSI 原始文本
80
80
  indicator: str = "" # 首列字符原文
81
81
  ansi_indicator: str = "" # 带 ANSI 颜色的首列字符
82
+ selected_value: str = "" # 当前 ❯ 光标所在选项的 value(数字字符串)
82
83
 
83
84
 
84
85
  # 向后兼容别名