remote-claude 1.0.6 → 1.0.8

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/bin/cdx CHANGED
@@ -13,9 +13,6 @@ if ! command -v uv &>/dev/null; then
13
13
  [ -f "$HOME/.local/bin/uv" ] && export PATH="$HOME/.local/bin:$PATH"
14
14
  fi
15
15
 
16
- # 检查飞书配置
17
- source "$SCRIPT_DIR/scripts/check-env.sh" "$SCRIPT_DIR"
18
-
19
16
  # 会话名:第一个参数非 - 开头时作为自定义名,否则用 PWD_时间戳
20
17
  if [ -n "$1" ] && [[ "$1" != -* ]]; then
21
18
  SESSION_NAME="$1"
package/bin/cl CHANGED
@@ -13,9 +13,6 @@ if ! command -v uv &>/dev/null; then
13
13
  [ -f "$HOME/.local/bin/uv" ] && export PATH="$HOME/.local/bin:$PATH"
14
14
  fi
15
15
 
16
- # 检查飞书配置
17
- source "$SCRIPT_DIR/scripts/check-env.sh" "$SCRIPT_DIR"
18
-
19
16
  # 会话名:第一个参数非 - 开头时作为自定义名,否则用 PWD_时间戳
20
17
  if [ -n "$1" ] && [[ "$1" != -* ]]; then
21
18
  SESSION_NAME="$1"
package/bin/cla CHANGED
@@ -13,9 +13,6 @@ if ! command -v uv &>/dev/null; then
13
13
  [ -f "$HOME/.local/bin/uv" ] && export PATH="$HOME/.local/bin:$PATH"
14
14
  fi
15
15
 
16
- # 检查飞书配置
17
- source "$SCRIPT_DIR/scripts/check-env.sh" "$SCRIPT_DIR"
18
-
19
16
  # 会话名:第一个参数非 - 开头时作为自定义名,否则用 PWD_时间戳
20
17
  if [ -n "$1" ] && [[ "$1" != -* ]]; then
21
18
  SESSION_NAME="$1"
package/bin/cx CHANGED
@@ -13,9 +13,6 @@ if ! command -v uv &>/dev/null; then
13
13
  [ -f "$HOME/.local/bin/uv" ] && export PATH="$HOME/.local/bin:$PATH"
14
14
  fi
15
15
 
16
- # 检查飞书配置
17
- source "$SCRIPT_DIR/scripts/check-env.sh" "$SCRIPT_DIR"
18
-
19
16
  # 会话名:第一个参数非 - 开头时作为自定义名,否则用 PWD_时间戳
20
17
  if [ -n "$1" ] && [[ "$1" != -* ]]; then
21
18
  SESSION_NAME="$1"
@@ -87,42 +87,61 @@ class LarkHandler:
87
87
  self._detached_slices: Dict[str, CardSlice] = {}
88
88
  # 正在启动中的会话名集合(防止并发点击触发竞态)
89
89
  self._starting_sessions: set = set()
90
+ logger.info(
91
+ f"LarkHandler 初始化: bindings={len(self._chat_bindings)}, "
92
+ f"groups={len(self._group_chat_ids)}, "
93
+ f"binding_details={{{', '.join(f'{k[:8]}...→{v}' for k, v in self._chat_bindings.items())}}}"
94
+ )
90
95
 
91
96
  # ── 持久化绑定 ──────────────────────────────────────────────────────────
92
97
 
93
- def _load_chat_bindings(self) -> Dict[str, str]:
98
+ def _load_json_file(self, path: Path):
99
+ """原子读取 JSON 文件,损坏时尝试从 .tmp 备份恢复"""
94
100
  try:
95
- if self._CHAT_BINDINGS_FILE.exists():
96
- return json.loads(self._CHAT_BINDINGS_FILE.read_text())
101
+ if path.exists():
102
+ content = path.read_text()
103
+ if content.strip():
104
+ return json.loads(content)
105
+ except json.JSONDecodeError as e:
106
+ logger.warning(f"文件 {path.name} JSON 损坏: {e},尝试恢复备份")
107
+ tmp_path = path.with_suffix('.json.tmp')
108
+ if tmp_path.exists():
109
+ try:
110
+ content = tmp_path.read_text()
111
+ if content.strip():
112
+ result = json.loads(content)
113
+ logger.warning(f"从 {tmp_path.name} 恢复成功")
114
+ return result
115
+ except Exception:
116
+ pass
97
117
  except Exception:
98
118
  pass
99
- return {}
119
+ return None
100
120
 
101
- def _save_chat_bindings(self):
121
+ def _atomic_write_json(self, path: Path, data):
122
+ """原子写入 JSON 文件:先写 .tmp 再 rename"""
102
123
  try:
103
124
  ensure_user_data_dir()
104
- self._CHAT_BINDINGS_FILE.write_text(
105
- json.dumps(self._chat_bindings, ensure_ascii=False)
106
- )
125
+ text = json.dumps(data, ensure_ascii=False)
126
+ tmp_path = path.with_suffix('.json.tmp')
127
+ tmp_path.write_text(text)
128
+ tmp_path.rename(path)
107
129
  except Exception as e:
108
- logger.warning(f"保存绑定失败: {e}")
130
+ logger.warning(f"保存 {path.name} 失败: {e}")
131
+
132
+ def _load_chat_bindings(self) -> Dict[str, str]:
133
+ result = self._load_json_file(self._CHAT_BINDINGS_FILE)
134
+ return result if result is not None else {}
135
+
136
+ def _save_chat_bindings(self):
137
+ self._atomic_write_json(self._CHAT_BINDINGS_FILE, self._chat_bindings)
109
138
 
110
139
  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()
140
+ result = self._load_json_file(self._LARK_GROUP_IDS_FILE)
141
+ return set(result) if result is not None else set()
117
142
 
118
143
  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}")
144
+ self._atomic_write_json(self._LARK_GROUP_IDS_FILE, list(self._group_chat_ids))
126
145
 
127
146
  def _remove_binding_by_chat(self, chat_id: str, force: bool = False):
128
147
  """移除 chat_id 的绑定。
@@ -131,6 +150,9 @@ class LarkHandler:
131
150
  """
132
151
  if not force and chat_id in self._group_chat_ids:
133
152
  return
153
+ session = self._chat_bindings.get(chat_id)
154
+ if session is not None:
155
+ logger.info(f"移除绑定: chat_id={chat_id[:8]}..., session={session}, force={force}")
134
156
  self._chat_bindings.pop(chat_id, None)
135
157
  self._save_chat_bindings()
136
158
 
@@ -139,6 +161,11 @@ class LarkHandler:
139
161
  async def _attach(self, chat_id: str, session_name: str,
140
162
  user_id: Optional[str] = None) -> bool:
141
163
  """统一 attach 逻辑(私聊/群聊共用)"""
164
+ logger.info(
165
+ f"[ATTACH] 开始: chat_id={chat_id[:8]}..., session={session_name}, "
166
+ f"old_session={self._chat_sessions.get(chat_id)}, "
167
+ f"old_bridge={'有' if chat_id in self._bridges else '无'}"
168
+ )
142
169
  # 在断开旧连接之前,先更新旧流式卡片为已断开状态
143
170
  old_session = self._chat_sessions.get(chat_id)
144
171
  old_slice = self._poller.stop_and_get_active_slice(chat_id)
@@ -165,11 +192,15 @@ class LarkHandler:
165
192
  notify_user_id=user_id)
166
193
  _track_stats('lark', 'attach', session_name=session_name,
167
194
  chat_id=chat_id)
195
+ logger.info(f"[ATTACH] 成功: chat_id={chat_id[:8]}..., session={session_name}")
168
196
  return True
197
+ logger.warning(f"[ATTACH] 失败: chat_id={chat_id[:8]}..., session={session_name}")
169
198
  return False
170
199
 
171
200
  async def _detach(self, chat_id: str):
172
201
  """统一 detach 逻辑(私聊/群聊共用)"""
202
+ session_name = self._chat_sessions.get(chat_id, "未知")
203
+ logger.info(f"[DETACH] chat_id={chat_id[:8]}..., session={session_name}")
173
204
  bridge = self._bridges.pop(chat_id, None)
174
205
  if bridge:
175
206
  await bridge.disconnect()
@@ -185,7 +216,12 @@ class LarkHandler:
185
216
  logger.info(f"会话 '{session_name}' 旧连接断线(已被替换),跳过清理")
186
217
  return
187
218
 
188
- logger.info(f"会话 '{session_name}' 断线, chat_id={chat_id[:8]}...")
219
+ bridge_running = disconnected_bridge.running if disconnected_bridge else "N/A"
220
+ logger.info(f"会话 '{session_name}' 断线, chat_id={chat_id[:8]}..., bridge_running={bridge_running}")
221
+ logger.info(
222
+ f"[DISCONNECT] 状态快照: bridges={[k[:8] for k in self._bridges]}, "
223
+ f"sessions={{{', '.join(f'{k[:8]}→{v}' for k, v in self._chat_sessions.items())}}}"
224
+ )
189
225
  _track_stats('lark', 'disconnect', session_name=session_name,
190
226
  chat_id=chat_id)
191
227
  active_slice = self._poller.stop_and_get_active_slice(chat_id)
@@ -196,7 +232,12 @@ class LarkHandler:
196
232
  # 只有用户主动 /detach 或 /kill 时才清除绑定
197
233
 
198
234
  if active_slice:
199
- await self._update_card_disconnected(chat_id, session_name, active_slice)
235
+ updated = await self._update_card_disconnected(chat_id, session_name, active_slice)
236
+ if not updated:
237
+ # 就地更新失败 → 降级发新卡片,确保用户看到断开状态
238
+ logger.warning(f"断线卡片就地更新失败,降级发送新卡: {chat_id[:8]}...")
239
+ card = build_stream_card([], disconnected=True, session_name=session_name)
240
+ await card_service.create_and_send_card(chat_id, card)
200
241
 
201
242
  # ── 消息入口 ────────────────────────────────────────────────────────────
202
243
 
@@ -280,6 +321,7 @@ class LarkHandler:
280
321
 
281
322
  ok = await self._attach(chat_id, session_name, user_id=user_id)
282
323
  if ok:
324
+ logger.info(f"[BINDING] 写入: chat_id={chat_id[:8]}..., session={session_name}, source=cmd_attach")
283
325
  self._chat_bindings[chat_id] = session_name
284
326
  self._save_chat_bindings()
285
327
  if message_id:
@@ -406,6 +448,7 @@ class LarkHandler:
406
448
 
407
449
  ok = await self._attach(chat_id, session_name, user_id=user_id)
408
450
  if ok:
451
+ logger.info(f"[BINDING] 写入: chat_id={chat_id[:8]}..., session={session_name}, source=cmd_start")
409
452
  self._chat_bindings[chat_id] = session_name
410
453
  self._save_chat_bindings()
411
454
  else:
@@ -520,6 +563,7 @@ class LarkHandler:
520
563
 
521
564
  # 清理所有残留绑定(包括已断开的群聊,其绑定在断开时被保留)
522
565
  for cid in [c for c, s in list(self._chat_bindings.items()) if s == session_name]:
566
+ logger.info(f"[KILL] 清理残留绑定: chat_id={cid[:8]}..., session={session_name}, is_group={'是' if cid in self._group_chat_ids else '否'}")
523
567
  self._group_chat_ids.discard(cid)
524
568
  self._chat_bindings.pop(cid, None)
525
569
  self._save_chat_bindings()
@@ -547,7 +591,7 @@ class LarkHandler:
547
591
 
548
592
  async def _update_card_disconnected(self, chat_id: str, session_name: str,
549
593
  active_slice: 'CardSlice') -> bool:
550
- """读取最新 blocks 并就地更新卡片为断开状态(disconnected=True)。Best-effort,不降级发新卡。"""
594
+ """读取最新 blocks 并就地更新卡片为断开状态(disconnected=True)。返回是否成功。"""
551
595
  blocks = []
552
596
  try:
553
597
  import sys as _sys
@@ -795,6 +839,7 @@ class LarkHandler:
795
839
  self._save_chat_bindings()
796
840
  self._group_chat_ids.add(group_chat_id)
797
841
  self._save_group_chat_ids()
842
+ logger.info(f"创建群聊成功: group_chat_id={group_chat_id[:8]}..., session={session_name}")
798
843
  # 立即 attach,让新群即刻开始接收 Claude 输出
799
844
  await self._attach(group_chat_id, session_name, user_id=user_id)
800
845
 
@@ -885,6 +930,7 @@ class LarkHandler:
885
930
  logger.error(f"解散群 API 失败: {feishu_msg}")
886
931
 
887
932
  # 无论 Feishu delete 是否成功,都清理本地绑定
933
+ logger.info(f"[DISBAND] 单群解散: group_chat_id={group_chat_id[:8]}..., session={session_name}")
888
934
  self._group_chat_ids.discard(group_chat_id)
889
935
  self._save_group_chat_ids()
890
936
  self._remove_binding_by_chat(group_chat_id, force=True)
@@ -907,6 +953,8 @@ class LarkHandler:
907
953
  bridge = self._bridges.get(chat_id)
908
954
  if bridge and bridge.running:
909
955
  return bridge
956
+ if bridge and not bridge.running:
957
+ logger.info(f"bridge 存在但已停止: chat_id={chat_id[:8]}..., session={self._chat_sessions.get(chat_id)}")
910
958
  # 尝试从持久化绑定恢复
911
959
  saved_session = self._chat_bindings.get(chat_id)
912
960
  if saved_session:
@@ -914,11 +962,28 @@ class LarkHandler:
914
962
  ok = await self._attach(chat_id, saved_session, user_id=user_id)
915
963
  if ok:
916
964
  return self._bridges.get(chat_id)
917
- # 恢复失败:会话已不存在,清除绑定
965
+ # 区分临时失败和永久失败
966
+ sessions = list_active_sessions()
967
+ if any(s["name"] == saved_session for s in sessions):
968
+ # 会话存在但连接失败 → 临时性故障,保留绑定供重试
969
+ logger.warning(f"自动恢复失败(临时性): session={saved_session}, 保留绑定")
970
+ return None
971
+ # 会话已不存在 → 永久失败,级联清除
972
+ logger.warning(f"会话 '{saved_session}' 已不存在,清除绑定: chat_id={chat_id[:8]}...")
973
+ # 1. 先停止当前 chat_id 的轮询器和 bridge
974
+ self._poller.stop(chat_id)
975
+ stale_bridge = self._bridges.pop(chat_id, None)
976
+ if stale_bridge:
977
+ await stale_bridge.disconnect()
978
+ self._chat_sessions.pop(chat_id, None)
979
+ self._detached_slices.pop(chat_id, None)
980
+ # 2. 清除绑定
918
981
  self._group_chat_ids.discard(chat_id)
919
- self._save_group_chat_ids()
920
982
  self._remove_binding_by_chat(chat_id, force=True)
983
+ # 3. 解散同会话的其他群聊(内部会清理各自的轮询器/bridge/绑定)
921
984
  await self._disband_groups_for_session(saved_session, source="lazy")
985
+ # 4. 确保持久化保存(disband 按绑定匹配遍历,已在上面移除绑定,确保保存)
986
+ self._save_group_chat_ids()
922
987
  return None
923
988
 
924
989
  async def _forward_to_claude(self, user_id: str, chat_id: str, text: str):
@@ -1139,14 +1204,15 @@ class LarkHandler:
1139
1204
  await card_service.send_text(chat_id, f"❌ 无法解析按键:`{combo}`\n使用 `/press` 查看支持的按键格式")
1140
1205
  return
1141
1206
 
1142
- bridge = self._bridges.get(chat_id)
1143
- if not bridge or not bridge.running:
1207
+ bridge = await self._ensure_bridge(chat_id, user_id=user_id)
1208
+ if not bridge:
1144
1209
  await card_service.send_text(chat_id, "❌ 当前未连接到会话,请先使用 /attach 连接")
1145
1210
  return
1146
1211
 
1147
1212
  success = await bridge.send_raw(raw)
1148
1213
  if success:
1149
1214
  logger.info(f"[press] 发送按键 {combo!r} ({raw!r}) 到会话")
1215
+ self._poller.kick(chat_id)
1150
1216
  else:
1151
1217
  await card_service.send_text(chat_id, f"❌ 发送按键 `{combo}` 失败")
1152
1218
 
@@ -1241,6 +1307,7 @@ class LarkHandler:
1241
1307
 
1242
1308
  async def disconnect_all_for_shutdown(self) -> None:
1243
1309
  """lark stop 时清理所有活跃流式卡片(更新为已断开状态)"""
1310
+ logger.info(f"[SHUTDOWN] 开始清理: bridges={len(self._bridges)}, sessions={len(self._chat_sessions)}")
1244
1311
  chat_ids = list(self._bridges.keys())
1245
1312
  for chat_id in chat_ids:
1246
1313
  session_name = self._chat_sessions.get(chat_id, "")
@@ -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.6",
3
+ "version": "1.0.8",
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()