remote-claude 1.0.7 → 1.0.9

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.
@@ -87,42 +87,63 @@ class LarkHandler:
87
87
  self._detached_slices: Dict[str, CardSlice] = {}
88
88
  # 正在启动中的会话名集合(防止并发点击触发竞态)
89
89
  self._starting_sessions: set = set()
90
+ # Session 健康检查后台任务
91
+ self._health_check_task: Optional[asyncio.Task] = None
92
+ logger.info(
93
+ f"LarkHandler 初始化: bindings={len(self._chat_bindings)}, "
94
+ f"groups={len(self._group_chat_ids)}, "
95
+ f"binding_details={{{', '.join(f'{k[:8]}...→{v}' for k, v in self._chat_bindings.items())}}}"
96
+ )
90
97
 
91
98
  # ── 持久化绑定 ──────────────────────────────────────────────────────────
92
99
 
93
- def _load_chat_bindings(self) -> Dict[str, str]:
100
+ def _load_json_file(self, path: Path):
101
+ """原子读取 JSON 文件,损坏时尝试从 .tmp 备份恢复"""
94
102
  try:
95
- if self._CHAT_BINDINGS_FILE.exists():
96
- return json.loads(self._CHAT_BINDINGS_FILE.read_text())
103
+ if path.exists():
104
+ content = path.read_text()
105
+ if content.strip():
106
+ return json.loads(content)
107
+ except json.JSONDecodeError as e:
108
+ logger.warning(f"文件 {path.name} JSON 损坏: {e},尝试恢复备份")
109
+ tmp_path = path.with_suffix('.json.tmp')
110
+ if tmp_path.exists():
111
+ try:
112
+ content = tmp_path.read_text()
113
+ if content.strip():
114
+ result = json.loads(content)
115
+ logger.warning(f"从 {tmp_path.name} 恢复成功")
116
+ return result
117
+ except Exception:
118
+ pass
97
119
  except Exception:
98
120
  pass
99
- return {}
121
+ return None
100
122
 
101
- def _save_chat_bindings(self):
123
+ def _atomic_write_json(self, path: Path, data):
124
+ """原子写入 JSON 文件:先写 .tmp 再 rename"""
102
125
  try:
103
126
  ensure_user_data_dir()
104
- self._CHAT_BINDINGS_FILE.write_text(
105
- json.dumps(self._chat_bindings, ensure_ascii=False)
106
- )
127
+ text = json.dumps(data, ensure_ascii=False)
128
+ tmp_path = path.with_suffix('.json.tmp')
129
+ tmp_path.write_text(text)
130
+ tmp_path.rename(path)
107
131
  except Exception as e:
108
- logger.warning(f"保存绑定失败: {e}")
132
+ logger.warning(f"保存 {path.name} 失败: {e}")
133
+
134
+ def _load_chat_bindings(self) -> Dict[str, str]:
135
+ result = self._load_json_file(self._CHAT_BINDINGS_FILE)
136
+ return result if result is not None else {}
137
+
138
+ def _save_chat_bindings(self):
139
+ self._atomic_write_json(self._CHAT_BINDINGS_FILE, self._chat_bindings)
109
140
 
110
141
  def _load_group_chat_ids(self) -> set:
111
- try:
112
- if self._LARK_GROUP_IDS_FILE.exists():
113
- return set(json.loads(self._LARK_GROUP_IDS_FILE.read_text()))
114
- except Exception:
115
- pass
116
- return set()
142
+ result = self._load_json_file(self._LARK_GROUP_IDS_FILE)
143
+ return set(result) if result is not None else set()
117
144
 
118
145
  def _save_group_chat_ids(self):
119
- try:
120
- ensure_user_data_dir()
121
- self._LARK_GROUP_IDS_FILE.write_text(
122
- json.dumps(list(self._group_chat_ids), ensure_ascii=False)
123
- )
124
- except Exception as e:
125
- logger.warning(f"保存群聊 ID 失败: {e}")
146
+ self._atomic_write_json(self._LARK_GROUP_IDS_FILE, list(self._group_chat_ids))
126
147
 
127
148
  def _remove_binding_by_chat(self, chat_id: str, force: bool = False):
128
149
  """移除 chat_id 的绑定。
@@ -131,6 +152,9 @@ class LarkHandler:
131
152
  """
132
153
  if not force and chat_id in self._group_chat_ids:
133
154
  return
155
+ session = self._chat_bindings.get(chat_id)
156
+ if session is not None:
157
+ logger.info(f"移除绑定: chat_id={chat_id[:8]}..., session={session}, force={force}")
134
158
  self._chat_bindings.pop(chat_id, None)
135
159
  self._save_chat_bindings()
136
160
 
@@ -139,6 +163,12 @@ class LarkHandler:
139
163
  async def _attach(self, chat_id: str, session_name: str,
140
164
  user_id: Optional[str] = None) -> bool:
141
165
  """统一 attach 逻辑(私聊/群聊共用)"""
166
+ self._ensure_health_check_started()
167
+ logger.info(
168
+ f"[ATTACH] 开始: chat_id={chat_id[:8]}..., session={session_name}, "
169
+ f"old_session={self._chat_sessions.get(chat_id)}, "
170
+ f"old_bridge={'有' if chat_id in self._bridges else '无'}"
171
+ )
142
172
  # 在断开旧连接之前,先更新旧流式卡片为已断开状态
143
173
  old_session = self._chat_sessions.get(chat_id)
144
174
  old_slice = self._poller.stop_and_get_active_slice(chat_id)
@@ -165,11 +195,15 @@ class LarkHandler:
165
195
  notify_user_id=user_id)
166
196
  _track_stats('lark', 'attach', session_name=session_name,
167
197
  chat_id=chat_id)
198
+ logger.info(f"[ATTACH] 成功: chat_id={chat_id[:8]}..., session={session_name}")
168
199
  return True
200
+ logger.warning(f"[ATTACH] 失败: chat_id={chat_id[:8]}..., session={session_name}")
169
201
  return False
170
202
 
171
203
  async def _detach(self, chat_id: str):
172
204
  """统一 detach 逻辑(私聊/群聊共用)"""
205
+ session_name = self._chat_sessions.get(chat_id, "未知")
206
+ logger.info(f"[DETACH] chat_id={chat_id[:8]}..., session={session_name}")
173
207
  bridge = self._bridges.pop(chat_id, None)
174
208
  if bridge:
175
209
  await bridge.disconnect()
@@ -185,7 +219,12 @@ class LarkHandler:
185
219
  logger.info(f"会话 '{session_name}' 旧连接断线(已被替换),跳过清理")
186
220
  return
187
221
 
188
- logger.info(f"会话 '{session_name}' 断线, chat_id={chat_id[:8]}...")
222
+ bridge_running = disconnected_bridge.running if disconnected_bridge else "N/A"
223
+ logger.info(f"会话 '{session_name}' 断线, chat_id={chat_id[:8]}..., bridge_running={bridge_running}")
224
+ logger.info(
225
+ f"[DISCONNECT] 状态快照: bridges={[k[:8] for k in self._bridges]}, "
226
+ f"sessions={{{', '.join(f'{k[:8]}→{v}' for k, v in self._chat_sessions.items())}}}"
227
+ )
189
228
  _track_stats('lark', 'disconnect', session_name=session_name,
190
229
  chat_id=chat_id)
191
230
  active_slice = self._poller.stop_and_get_active_slice(chat_id)
@@ -196,13 +235,19 @@ class LarkHandler:
196
235
  # 只有用户主动 /detach 或 /kill 时才清除绑定
197
236
 
198
237
  if active_slice:
199
- await self._update_card_disconnected(chat_id, session_name, active_slice)
238
+ updated = await self._update_card_disconnected(chat_id, session_name, active_slice)
239
+ if not updated:
240
+ # 就地更新失败 → 降级发新卡片,确保用户看到断开状态
241
+ logger.warning(f"断线卡片就地更新失败,降级发送新卡: {chat_id[:8]}...")
242
+ card = build_stream_card([], disconnected=True, session_name=session_name)
243
+ await card_service.create_and_send_card(chat_id, card)
200
244
 
201
245
  # ── 消息入口 ────────────────────────────────────────────────────────────
202
246
 
203
247
  async def handle_message(self, user_id: str, chat_id: str, text: str,
204
248
  chat_type: str = "p2p"):
205
249
  """处理用户消息(群聊/私聊统一路由)"""
250
+ self._ensure_health_check_started()
206
251
  logger.info(f"收到消息: user={user_id[:8]}..., chat={chat_id[:8]}..., type={chat_type}, text={text[:50]}")
207
252
  text = text.strip()
208
253
 
@@ -280,6 +325,7 @@ class LarkHandler:
280
325
 
281
326
  ok = await self._attach(chat_id, session_name, user_id=user_id)
282
327
  if ok:
328
+ logger.info(f"[BINDING] 写入: chat_id={chat_id[:8]}..., session={session_name}, source=cmd_attach")
283
329
  self._chat_bindings[chat_id] = session_name
284
330
  self._save_chat_bindings()
285
331
  if message_id:
@@ -406,6 +452,7 @@ class LarkHandler:
406
452
 
407
453
  ok = await self._attach(chat_id, session_name, user_id=user_id)
408
454
  if ok:
455
+ logger.info(f"[BINDING] 写入: chat_id={chat_id[:8]}..., session={session_name}, source=cmd_start")
409
456
  self._chat_bindings[chat_id] = session_name
410
457
  self._save_chat_bindings()
411
458
  else:
@@ -520,6 +567,7 @@ class LarkHandler:
520
567
 
521
568
  # 清理所有残留绑定(包括已断开的群聊,其绑定在断开时被保留)
522
569
  for cid in [c for c, s in list(self._chat_bindings.items()) if s == session_name]:
570
+ logger.info(f"[KILL] 清理残留绑定: chat_id={cid[:8]}..., session={session_name}, is_group={'是' if cid in self._group_chat_ids else '否'}")
523
571
  self._group_chat_ids.discard(cid)
524
572
  self._chat_bindings.pop(cid, None)
525
573
  self._save_chat_bindings()
@@ -547,7 +595,7 @@ class LarkHandler:
547
595
 
548
596
  async def _update_card_disconnected(self, chat_id: str, session_name: str,
549
597
  active_slice: 'CardSlice') -> bool:
550
- """读取最新 blocks 并就地更新卡片为断开状态(disconnected=True)。Best-effort,不降级发新卡。"""
598
+ """读取最新 blocks 并就地更新卡片为断开状态(disconnected=True)。返回是否成功。"""
551
599
  blocks = []
552
600
  try:
553
601
  import sys as _sys
@@ -795,6 +843,7 @@ class LarkHandler:
795
843
  self._save_chat_bindings()
796
844
  self._group_chat_ids.add(group_chat_id)
797
845
  self._save_group_chat_ids()
846
+ logger.info(f"创建群聊成功: group_chat_id={group_chat_id[:8]}..., session={session_name}")
798
847
  # 立即 attach,让新群即刻开始接收 Claude 输出
799
848
  await self._attach(group_chat_id, session_name, user_id=user_id)
800
849
 
@@ -868,6 +917,68 @@ class LarkHandler:
868
917
  self._save_chat_bindings()
869
918
  self._save_group_chat_ids()
870
919
 
920
+ # ── Session 健康检查 ─────────────────────────────────────────────────────
921
+
922
+ HEALTH_CHECK_INTERVAL = 30 # 秒
923
+
924
+ def _ensure_health_check_started(self):
925
+ """懒启动健康检查后台任务(首次收到消息时调用)"""
926
+ if self._health_check_task is None or self._health_check_task.done():
927
+ self._health_check_task = asyncio.create_task(self._health_check_loop())
928
+ logger.info("Session 健康检查后台任务已启动")
929
+
930
+ async def _health_check_loop(self):
931
+ """定期检查绑定的 session 是否仍然存活"""
932
+ while True:
933
+ await asyncio.sleep(self.HEALTH_CHECK_INTERVAL)
934
+ try:
935
+ await self._session_health_check()
936
+ except Exception as e:
937
+ logger.error(f"健康检查异常: {e}", exc_info=True)
938
+
939
+ async def _session_health_check(self):
940
+ """扫描所有绑定关系,对已死亡 session 自动解散群聊、清理私聊绑定"""
941
+ bound_sessions = set(self._chat_bindings.values())
942
+ if not bound_sessions:
943
+ return
944
+
945
+ active = {s["name"] for s in list_active_sessions()}
946
+ dead_sessions = bound_sessions - active
947
+ if not dead_sessions:
948
+ return
949
+
950
+ for session_name in dead_sessions:
951
+ logger.info(f"[health-check] 检测到 session '{session_name}' 已关闭")
952
+
953
+ # 记录受影响的 chat_id(在 disband 修改 dict 之前)
954
+ affected_chats = [
955
+ cid for cid, sname in list(self._chat_bindings.items())
956
+ if sname == session_name
957
+ ]
958
+
959
+ # 解散群聊(复用已有逻辑,内部会清理群聊的绑定/轮询/bridge)
960
+ await self._disband_groups_for_session(session_name, source="health-check")
961
+
962
+ # 处理私聊绑定(非群聊的 chat_id)
963
+ cleaned_private = False
964
+ for cid in affected_chats:
965
+ if cid in self._group_chat_ids:
966
+ continue # 群聊已由 _disband_groups_for_session 处理
967
+ if self._chat_bindings.get(cid) != session_name:
968
+ continue # 已被其他逻辑清理
969
+ logger.info(f"[health-check] 清理私聊绑定: chat_id={cid[:8]}..., session={session_name}")
970
+ self._poller.stop(cid)
971
+ bridge = self._bridges.pop(cid, None)
972
+ if bridge:
973
+ await bridge.disconnect()
974
+ self._chat_sessions.pop(cid, None)
975
+ self._detached_slices.pop(cid, None)
976
+ self._remove_binding_by_chat(cid, force=True)
977
+ cleaned_private = True
978
+
979
+ if cleaned_private:
980
+ self._save_chat_bindings()
981
+
871
982
  async def _cmd_disband_group(self, user_id: str, chat_id: str, session_name: str,
872
983
  message_id: Optional[str] = None):
873
984
  """解散与指定会话绑定的专属群聊"""
@@ -885,6 +996,7 @@ class LarkHandler:
885
996
  logger.error(f"解散群 API 失败: {feishu_msg}")
886
997
 
887
998
  # 无论 Feishu delete 是否成功,都清理本地绑定
999
+ logger.info(f"[DISBAND] 单群解散: group_chat_id={group_chat_id[:8]}..., session={session_name}")
888
1000
  self._group_chat_ids.discard(group_chat_id)
889
1001
  self._save_group_chat_ids()
890
1002
  self._remove_binding_by_chat(group_chat_id, force=True)
@@ -907,6 +1019,8 @@ class LarkHandler:
907
1019
  bridge = self._bridges.get(chat_id)
908
1020
  if bridge and bridge.running:
909
1021
  return bridge
1022
+ if bridge and not bridge.running:
1023
+ logger.info(f"bridge 存在但已停止: chat_id={chat_id[:8]}..., session={self._chat_sessions.get(chat_id)}")
910
1024
  # 尝试从持久化绑定恢复
911
1025
  saved_session = self._chat_bindings.get(chat_id)
912
1026
  if saved_session:
@@ -914,11 +1028,28 @@ class LarkHandler:
914
1028
  ok = await self._attach(chat_id, saved_session, user_id=user_id)
915
1029
  if ok:
916
1030
  return self._bridges.get(chat_id)
917
- # 恢复失败:会话已不存在,清除绑定
1031
+ # 区分临时失败和永久失败
1032
+ sessions = list_active_sessions()
1033
+ if any(s["name"] == saved_session for s in sessions):
1034
+ # 会话存在但连接失败 → 临时性故障,保留绑定供重试
1035
+ logger.warning(f"自动恢复失败(临时性): session={saved_session}, 保留绑定")
1036
+ return None
1037
+ # 会话已不存在 → 永久失败,级联清除
1038
+ logger.warning(f"会话 '{saved_session}' 已不存在,清除绑定: chat_id={chat_id[:8]}...")
1039
+ # 1. 先停止当前 chat_id 的轮询器和 bridge
1040
+ self._poller.stop(chat_id)
1041
+ stale_bridge = self._bridges.pop(chat_id, None)
1042
+ if stale_bridge:
1043
+ await stale_bridge.disconnect()
1044
+ self._chat_sessions.pop(chat_id, None)
1045
+ self._detached_slices.pop(chat_id, None)
1046
+ # 2. 清除绑定
918
1047
  self._group_chat_ids.discard(chat_id)
919
- self._save_group_chat_ids()
920
1048
  self._remove_binding_by_chat(chat_id, force=True)
1049
+ # 3. 解散同会话的其他群聊(内部会清理各自的轮询器/bridge/绑定)
921
1050
  await self._disband_groups_for_session(saved_session, source="lazy")
1051
+ # 4. 确保持久化保存(disband 按绑定匹配遍历,已在上面移除绑定,确保保存)
1052
+ self._save_group_chat_ids()
922
1053
  return None
923
1054
 
924
1055
  async def _forward_to_claude(self, user_id: str, chat_id: str, text: str):
@@ -945,6 +1076,7 @@ class LarkHandler:
945
1076
  发箭头键导航到目标选项,每步从共享内存读取 selected_value 确认是否到位,
946
1077
  到位后发 Enter 确认。避免数字键在溢出选项上无效的问题。
947
1078
  """
1079
+ self._ensure_health_check_started()
948
1080
  logger.info(f"处理选项选择: user={user_id[:8]}..., option={option_value}, total={option_total}")
949
1081
  _track_stats('lark', 'option_select',
950
1082
  session_name=self._chat_sessions.get(chat_id, ''),
@@ -1139,14 +1271,15 @@ class LarkHandler:
1139
1271
  await card_service.send_text(chat_id, f"❌ 无法解析按键:`{combo}`\n使用 `/press` 查看支持的按键格式")
1140
1272
  return
1141
1273
 
1142
- bridge = self._bridges.get(chat_id)
1143
- if not bridge or not bridge.running:
1274
+ bridge = await self._ensure_bridge(chat_id, user_id=user_id)
1275
+ if not bridge:
1144
1276
  await card_service.send_text(chat_id, "❌ 当前未连接到会话,请先使用 /attach 连接")
1145
1277
  return
1146
1278
 
1147
1279
  success = await bridge.send_raw(raw)
1148
1280
  if success:
1149
1281
  logger.info(f"[press] 发送按键 {combo!r} ({raw!r}) 到会话")
1282
+ self._poller.kick(chat_id)
1150
1283
  else:
1151
1284
  await card_service.send_text(chat_id, f"❌ 发送按键 `{combo}` 失败")
1152
1285
 
@@ -1241,6 +1374,10 @@ class LarkHandler:
1241
1374
 
1242
1375
  async def disconnect_all_for_shutdown(self) -> None:
1243
1376
  """lark stop 时清理所有活跃流式卡片(更新为已断开状态)"""
1377
+ # 停止健康检查
1378
+ if self._health_check_task and not self._health_check_task.done():
1379
+ self._health_check_task.cancel()
1380
+ logger.info(f"[SHUTDOWN] 开始清理: bridges={len(self._bridges)}, sessions={len(self._chat_sessions)}")
1244
1381
  chat_ids = list(self._bridges.keys())
1245
1382
  for chat_id in chat_ids:
1246
1383
  session_name = self._chat_sessions.get(chat_id, "")
@@ -11,6 +11,8 @@ import logging
11
11
  import os
12
12
  import signal
13
13
  import sys
14
+ import threading
15
+ import time
14
16
  import urllib.request
15
17
  from pathlib import Path
16
18
 
@@ -155,6 +157,7 @@ def handle_card_action(event: P2CardActionTrigger) -> P2CardActionTriggerRespons
155
157
  action_value = action.value or {}
156
158
 
157
159
  print(f"[Lark] 收到卡片动作: user={user_id[:8]}..., action={action_value}")
160
+ handler._ensure_health_check_started()
158
161
 
159
162
  # 检查用户白名单
160
163
  if not check_user_allowed(user_id):
@@ -399,6 +402,17 @@ class LarkBot:
399
402
  print("\n机器人已启动,等待消息...")
400
403
  print("在飞书中发送 /help 查看使用说明\n")
401
404
 
405
+ # 在 ws_client.start() 阻塞前,保存事件循环引用,通过守护线程延迟启动健康检查
406
+ # (无需等待消息,15 秒后 WS 已连接就自动启动)
407
+ _loop = asyncio.get_event_loop()
408
+ def _deferred_health_check_start():
409
+ time.sleep(15)
410
+ try:
411
+ _loop.call_soon_threadsafe(handler._ensure_health_check_started)
412
+ except Exception as e:
413
+ print(f"[Lark] 健康检查启动失败: {e}")
414
+ threading.Thread(target=_deferred_health_check_start, daemon=True).start()
415
+
402
416
  # 启动 WebSocket(阻塞)
403
417
  self.ws_client.start()
404
418
 
@@ -99,6 +99,7 @@ class SessionBridge:
99
99
 
100
100
  async def disconnect(self):
101
101
  """断开连接"""
102
+ logger.info(f"主动断开: session={self.session_name}, running={self.running}")
102
103
  self._manually_disconnected = True
103
104
  self.running = False
104
105
  if self._read_task:
@@ -173,6 +174,7 @@ class SessionBridge:
173
174
  try:
174
175
  msg = await asyncio.wait_for(self._read_message(), timeout=1.0)
175
176
  if msg is None:
177
+ logger.info(f"read_loop 收到 EOF: session={self.session_name}")
176
178
  self.running = False
177
179
  break
178
180
  if msg.type == MessageType.INPUT and self.on_input:
@@ -183,7 +185,7 @@ class SessionBridge:
183
185
  except asyncio.CancelledError:
184
186
  break
185
187
  except Exception as e:
186
- logger.warning(f"读取错误: {e}")
188
+ logger.warning(f"读取错误: session={self.session_name}, error={e}")
187
189
  break
188
190
 
189
191
  if self.on_disconnect and not self._manually_disconnected:
@@ -113,12 +113,13 @@ class SharedMemoryPoller:
113
113
  self._rapid_until.pop(chat_id, None)
114
114
 
115
115
  tracker = self._trackers.pop(chat_id, None)
116
+ session_name = tracker.session_name if tracker else "N/A"
116
117
  if tracker and tracker.reader:
117
118
  try:
118
119
  tracker.reader.close()
119
120
  except Exception:
120
121
  pass
121
- logger.info(f"轮询器停止: chat_id={chat_id[:8]}...")
122
+ logger.info(f"轮询器停止: chat_id={chat_id[:8]}..., session={session_name}")
122
123
 
123
124
  def stop_and_get_active_slice(self, chat_id: str) -> Optional['CardSlice']:
124
125
  """停止轮询并返回活跃(未冻结)CardSlice,原子操作。供 detach/disconnect 就地更新卡片使用。"""
@@ -143,7 +144,8 @@ class SharedMemoryPoller:
143
144
  except Exception:
144
145
  pass
145
146
 
146
- logger.info(f"轮询器停止(含活跃切片): chat_id={chat_id[:8]}..., active={'有' if active else '无'}")
147
+ session_name = tracker.session_name if tracker else "N/A"
148
+ logger.info(f"轮询器停止(含活跃切片): chat_id={chat_id[:8]}..., session={session_name}, active={'有' if active else '无'}")
147
149
  return active
148
150
 
149
151
  def _on_task_done(self, task: asyncio.Task, chat_id: str) -> None:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "remote-claude",
3
- "version": "1.0.7",
3
+ "version": "1.0.9",
4
4
  "description": "双端共享 Claude CLI 工具",
5
5
  "bin": {
6
6
  "remote-claude": "bin/remote-claude",
package/remote_claude.py CHANGED
@@ -34,7 +34,7 @@ from utils.session import (
34
34
  tmux_kill_session,
35
35
  list_active_sessions, is_session_active, cleanup_session,
36
36
  is_lark_running, get_lark_pid, get_lark_status, get_lark_pid_file,
37
- save_lark_status, cleanup_lark,
37
+ save_lark_status, cleanup_lark, kill_all_lark_processes,
38
38
  USER_DATA_DIR, ensure_user_data_dir, get_lark_log_file,
39
39
  get_env_snapshot_path,
40
40
  )
@@ -214,10 +214,28 @@ def cmd_list(args):
214
214
  return 0
215
215
 
216
216
 
217
+ def _find_session_by_pid(pid: int):
218
+ """通过 PID 查找对应会话名"""
219
+ for session in list_active_sessions():
220
+ if session.get("pid") == pid:
221
+ return session["name"]
222
+ return None
223
+
224
+
217
225
  def cmd_kill(args):
218
226
  """终止会话"""
219
227
  session_name = args.name
220
228
 
229
+ # 若参数是纯数字,按 PID 反查会话名
230
+ if session_name.isdigit():
231
+ pid = int(session_name)
232
+ matched = _find_session_by_pid(pid)
233
+ if not matched:
234
+ print(f"错误: 未找到 PID={pid} 对应的会话")
235
+ return 1
236
+ session_name = matched
237
+ print(f"PID {pid} 对应会话: {session_name}")
238
+
221
239
  # 检查会话是否存在
222
240
  if not is_session_active(session_name) and not tmux_session_exists(session_name):
223
241
  print(f"错误: 会话 '{session_name}' 不存在")
@@ -307,6 +325,11 @@ def cmd_lark_start(args):
307
325
  ensure_socket_dir()
308
326
  ensure_user_data_dir()
309
327
 
328
+ # 启动前清理残留进程
329
+ stale = kill_all_lark_processes()
330
+ if stale:
331
+ print(f" 清理了 {len(stale)} 个残留进程: {stale}")
332
+
310
333
  # 启动守护进程(使用 -m 模块方式运行,确保相对导入正常工作)
311
334
  log_file = get_lark_log_file()
312
335
 
@@ -364,8 +387,11 @@ def cmd_lark_stop(args):
364
387
  print(f"正在停止飞书客户端 (PID: {pid})...")
365
388
 
366
389
  try:
367
- # 发送 SIGTERM 信号
368
- os.kill(pid, signal.SIGTERM)
390
+ # 杀整个进程组(uv 父进程 + Python 子进程一起杀)
391
+ try:
392
+ os.killpg(os.getpgid(pid), signal.SIGTERM)
393
+ except (ProcessLookupError, PermissionError):
394
+ os.kill(pid, signal.SIGTERM) # 降级
369
395
 
370
396
  # 等待进程退出
371
397
  for i in range(50): # 最多等待 5 秒
@@ -375,9 +401,17 @@ def cmd_lark_stop(args):
375
401
  else:
376
402
  # 如果还没退出,强制终止
377
403
  print("进程未响应,强制终止...")
378
- os.kill(pid, signal.SIGKILL)
404
+ try:
405
+ os.killpg(os.getpgid(pid), signal.SIGKILL)
406
+ except (ProcessLookupError, PermissionError):
407
+ os.kill(pid, signal.SIGKILL)
379
408
  time.sleep(0.5)
380
409
 
410
+ # 清理所有残留 lark_client 进程
411
+ stale = kill_all_lark_processes()
412
+ if stale:
413
+ print(f" 清理了 {len(stale)} 个残留进程: {stale}")
414
+
381
415
  if not is_lark_running():
382
416
  print("✓ 飞书客户端已停止")
383
417
  cleanup_lark()
@@ -389,6 +423,10 @@ def cmd_lark_stop(args):
389
423
  return 1
390
424
 
391
425
  except ProcessLookupError:
426
+ # 清理所有残留 lark_client 进程
427
+ stale = kill_all_lark_processes()
428
+ if stale:
429
+ print(f" 清理了 {len(stale)} 个残留进程: {stale}")
392
430
  print("进程已不存在,清理残留文件")
393
431
  cleanup_lark()
394
432
  _stop_watchdog()
@@ -817,7 +855,7 @@ def main():
817
855
 
818
856
  # kill 命令
819
857
  kill_parser = subparsers.add_parser("kill", help="终止会话")
820
- kill_parser.add_argument("name", help="会话名称")
858
+ kill_parser.add_argument("name", help="会话名称或 PID")
821
859
  kill_parser.set_defaults(func=cmd_kill)
822
860
 
823
861
  # status 命令
package/utils/session.py CHANGED
@@ -519,6 +519,47 @@ def save_lark_status(pid: int):
519
519
  status_file.write_text(json.dumps(status_data))
520
520
 
521
521
 
522
+ def kill_all_lark_processes(exclude_pid: int = None) -> list:
523
+ """查找并杀死所有 lark_client 相关进程(排除指定 PID)
524
+
525
+ 返回被杀死的 PID 列表
526
+ """
527
+ import signal
528
+ import time
529
+ killed = []
530
+
531
+ # 查找所有 lark_client.main 相关进程
532
+ try:
533
+ result = subprocess.run(
534
+ ["pgrep", "-f", "lark_client.main"],
535
+ capture_output=True, text=True, timeout=5
536
+ )
537
+ pids = [int(p) for p in result.stdout.strip().split('\n') if p.strip()]
538
+ except Exception:
539
+ return killed
540
+
541
+ for pid in pids:
542
+ if pid == exclude_pid or pid == os.getpid():
543
+ continue
544
+ try:
545
+ os.kill(pid, signal.SIGTERM)
546
+ killed.append(pid)
547
+ except (ProcessLookupError, PermissionError):
548
+ pass
549
+
550
+ # 等待退出,必要时 SIGKILL
551
+ if killed:
552
+ time.sleep(1)
553
+ for pid in killed:
554
+ try:
555
+ os.kill(pid, 0) # 检查是否还活着
556
+ os.kill(pid, signal.SIGKILL)
557
+ except (ProcessLookupError, PermissionError):
558
+ pass
559
+
560
+ return killed
561
+
562
+
522
563
  def cleanup_lark():
523
564
  """清理飞书客户端残留文件"""
524
565
  pid_file = get_lark_pid_file()