remote-claude 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -202,8 +202,8 @@ class SharedMemoryPoller:
202
202
  logger.error(f"_poll_once 异常: {e}", exc_info=True)
203
203
 
204
204
  async def _poll_once(self, tracker: StreamTracker) -> None:
205
- """单次轮询:读取共享内存 → diff → 创建/更新卡片"""
206
- # 延迟初始化 Reader
205
+ """单次轮询:读取共享内存 → diff → 创建/更新卡片 → 就绪通知"""
206
+ # 步骤 1:延迟初始化 Reader
207
207
  if tracker.reader is None:
208
208
  try:
209
209
  from shared_state import get_mq_path, SharedStateReader
@@ -230,94 +230,114 @@ class SharedMemoryPoller:
230
230
  agent_panel = state.get("agent_panel")
231
231
  option_block = state.get("option_block")
232
232
  cli_type = state.get("cli_type", "claude")
233
+ # timestamp 存在说明 server 已写入有效快照(即使内容全空,如 Codex 就绪等待输入)
234
+ has_valid_snapshot = state.get("timestamp") is not None
233
235
 
234
- # 就绪状态转换检测
235
- await self._check_ready_notification(tracker, blocks, status_line, option_block, cli_type)
236
+ # 步骤 2:仅计算就绪状态,不发送通知
237
+ should_notify = self._update_ready_state(tracker, blocks, status_line, option_block, agent_panel)
236
238
 
239
+ # 步骤 3:卡片操作(含创建/更新/拆分)
240
+ await self._do_card_update(tracker, blocks, status_line, bottom_bar, agent_panel, option_block, cli_type, has_valid_snapshot=has_valid_snapshot)
241
+
242
+ # 步骤 4:通知在卡片操作之后发送,确保新卡先出现
243
+ if should_notify:
244
+ await self._send_ready_notification(tracker, cli_type)
245
+
246
+ async def _do_card_update(
247
+ self, tracker: StreamTracker, blocks: List[dict],
248
+ status_line: Optional[dict], bottom_bar: Optional[dict],
249
+ agent_panel: Optional[dict], option_block: Optional[dict],
250
+ cli_type: str,
251
+ has_valid_snapshot: bool = False,
252
+ ) -> None:
253
+ """卡片操作主体:获取活跃卡片 → 创建/更新/拆分"""
237
254
  # 获取活跃卡片(最后一张且未冻结)
238
255
  active = None
239
256
  if tracker.cards and not tracker.cards[-1].frozen:
240
257
  active = tracker.cards[-1]
241
258
 
242
259
  if not blocks and not status_line and not bottom_bar and not agent_panel and not option_block and active is None:
243
- return # 完全无内容且无活跃卡片时不创建卡片
260
+ if not has_valid_snapshot:
261
+ return # 真的还没有快照,不创建卡片
262
+ # 有效快照但内容全空(如 Codex 就绪等待输入),继续创建就绪卡片
244
263
 
245
264
  if active is None:
246
265
  # 需要创建新卡片
247
266
  await self._create_new_card(tracker, blocks, status_line, bottom_bar, agent_panel, option_block, cli_type=cli_type)
248
- else:
249
- # 有活跃卡片,检查是否需要更新
250
- blocks_slice = blocks[active.start_idx:]
267
+ return
251
268
 
252
- # blocks 骤降检测(compact/重启导致 blocks 从头累积)
253
- if len(blocks) < active.start_idx:
254
- logger.warning(
255
- f"[blocks regression] len(blocks)={len(blocks)} < start_idx={active.start_idx}, "
256
- f"resetting start_idx to 0 (session={tracker.session_name})"
257
- )
258
- active.start_idx = 0
259
- blocks_slice = blocks[0:]
260
- tracker.content_hash = "" # 强制刷新
269
+ # 有活跃卡片,检查是否需要更新
270
+ blocks_slice = blocks[active.start_idx:]
261
271
 
262
- # 超限检查
263
- if len(blocks_slice) > MAX_CARD_BLOCKS:
264
- await self._freeze_and_split(tracker, blocks, status_line, bottom_bar, agent_panel, option_block, cli_type=cli_type)
265
- return
272
+ # blocks 骤降检测(compact/重启导致 blocks 从头累积)
273
+ if len(blocks) < active.start_idx:
274
+ logger.warning(
275
+ f"[blocks regression] len(blocks)={len(blocks)} < start_idx={active.start_idx}, "
276
+ f"resetting start_idx to 0 (session={tracker.session_name})"
277
+ )
278
+ active.start_idx = 0
279
+ blocks_slice = blocks[0:]
280
+ tracker.content_hash = "" # 强制刷新
266
281
 
267
- # hash diff
268
- new_hash = self._compute_hash(blocks_slice, status_line, bottom_bar, agent_panel, option_block)
269
- if new_hash == tracker.content_hash:
270
- return # 无变化
282
+ # 超限检查
283
+ if len(blocks_slice) > MAX_CARD_BLOCKS:
284
+ await self._freeze_and_split(tracker, blocks, status_line, bottom_bar, agent_panel, option_block, cli_type=cli_type)
285
+ return
271
286
 
272
- # 更新卡片
273
- from .card_builder import build_stream_card
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)
287
+ # hash diff
288
+ new_hash = self._compute_hash(blocks_slice, status_line, bottom_bar, agent_panel, option_block)
289
+ if new_hash == tracker.content_hash:
290
+ return # 无变化
275
291
 
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
292
+ # 更新卡片
293
+ from .card_builder import build_stream_card
294
+ 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)
285
295
 
286
- active.sequence += 1
287
- success = await self._card_service.update_card(
288
- card_id=active.card_id,
289
- sequence=active.sequence,
290
- card_content=card_dict,
296
+ # 大小超限检查(与 blocks 数量超限同一套逻辑)
297
+ card_size = len(json.dumps(card_dict, ensure_ascii=False).encode('utf-8'))
298
+ if card_size > CARD_SIZE_LIMIT:
299
+ freeze_count = self._find_freeze_count(blocks_slice, tracker.session_name)
300
+ await self._freeze_and_split(
301
+ tracker, blocks, status_line, bottom_bar, agent_panel, option_block,
302
+ cli_type=cli_type, freeze_count=freeze_count,
291
303
  )
304
+ return
292
305
 
293
- if getattr(success, 'is_element_limit', False):
294
- # 元素超限:冻结旧卡 + 推新流式卡
295
- await self._handle_element_limit(
296
- tracker, blocks, status_line, bottom_bar, agent_panel, option_block,
297
- cli_type=cli_type,
298
- )
299
- return
300
- elif not success:
301
- # 降级:创建新卡片替代
302
- logger.warning(
303
- f"update_card 失败 card_id={active.card_id} seq={active.sequence},降级为新卡片"
304
- )
305
- _track_stats('card', 'fallback', session_name=tracker.session_name,
306
- chat_id=tracker.chat_id)
307
- new_card_id = await self._card_service.create_card(card_dict)
308
- if new_card_id:
309
- await self._card_service.send_card(tracker.chat_id, new_card_id)
310
- active.card_id = new_card_id
311
- active.sequence = 0
312
- else:
313
- _track_stats('card', 'update', session_name=tracker.session_name,
314
- chat_id=tracker.chat_id)
306
+ active.sequence += 1
307
+ success = await self._card_service.update_card(
308
+ card_id=active.card_id,
309
+ sequence=active.sequence,
310
+ card_content=card_dict,
311
+ )
315
312
 
316
- tracker.content_hash = new_hash
317
- logger.debug(
318
- f"[UPDATE] session={tracker.session_name} blocks={len(blocks_slice)} "
319
- f"seq={active.sequence} hash={new_hash[:8]}"
313
+ if getattr(success, 'is_element_limit', False):
314
+ # 元素超限:冻结旧卡 + 推新流式卡
315
+ await self._handle_element_limit(
316
+ tracker, blocks, status_line, bottom_bar, agent_panel, option_block,
317
+ cli_type=cli_type,
318
+ )
319
+ return
320
+ elif not success:
321
+ # 降级:创建新卡片替代
322
+ logger.warning(
323
+ f"update_card 失败 card_id={active.card_id} seq={active.sequence},降级为新卡片"
320
324
  )
325
+ _track_stats('card', 'fallback', session_name=tracker.session_name,
326
+ chat_id=tracker.chat_id)
327
+ new_card_id = await self._card_service.create_card(card_dict)
328
+ if new_card_id:
329
+ await self._card_service.send_card(tracker.chat_id, new_card_id)
330
+ active.card_id = new_card_id
331
+ active.sequence = 0
332
+ else:
333
+ _track_stats('card', 'update', session_name=tracker.session_name,
334
+ chat_id=tracker.chat_id)
335
+
336
+ tracker.content_hash = new_hash
337
+ logger.debug(
338
+ f"[UPDATE] session={tracker.session_name} blocks={len(blocks_slice)} "
339
+ f"seq={active.sequence} hash={new_hash[:8]}"
340
+ )
321
341
 
322
342
  async def _create_new_card(
323
343
  self, tracker: StreamTracker, blocks: List[dict],
@@ -342,8 +362,8 @@ class SharedMemoryPoller:
342
362
  )
343
363
 
344
364
  blocks_slice = blocks[start_idx:]
345
- if not blocks_slice and not status_line and not bottom_bar and not agent_panel and not option_block:
346
- return
365
+ # 注意:不在此处提前 return,上层 _do_card_update 已做过滤,
366
+ # 走到这里说明确实需要创建卡片(如 Codex 就绪等待输入的空内容卡片)
347
367
 
348
368
  from .card_builder import build_stream_card
349
369
  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)
@@ -487,53 +507,57 @@ class SharedMemoryPoller:
487
507
  )
488
508
  tracker.last_notify_message_id = None
489
509
 
490
- async def _check_ready_notification(
510
+ def _update_ready_state(
491
511
  self, tracker: StreamTracker,
492
512
  blocks: list, status_line: Optional[dict], option_block: Optional[dict],
493
- cli_type: str = "claude"
494
- ) -> None:
495
- """检测就绪状态转换(忙碌→就绪),群聊中发送 @所有人 提醒"""
496
- current_ready = _is_ready(blocks, status_line, option_block)
513
+ agent_panel: Optional[dict] = None,
514
+ ) -> bool:
515
+ """更新就绪状态,返回是否需要发送就绪通知(不执行发送)"""
516
+ current_ready = _is_ready(blocks, status_line, option_block, agent_panel)
497
517
  prev_ready = tracker.prev_is_ready
498
518
  tracker.prev_is_ready = current_ready
519
+ return current_ready and not prev_ready and tracker.is_group and _notify_enabled
499
520
 
500
- if current_ready and not prev_ready and tracker.is_group and _notify_enabled:
501
- count = _increment_ready_count()
502
- uid = tracker.notify_user_id or "all"
503
- cli_name = "Claude" if cli_type == "claude" else "Codex"
504
- logger.info(f"就绪提醒: chat_id={tracker.chat_id[:8]}..., count={count}, uid={uid}, "
505
- f"last_msg={'有' if tracker.last_notify_message_id else '无'}")
506
-
507
- if tracker.last_notify_message_id and uid != "all" and _urgent_enabled:
508
- # 已有通知消息 + 加急开关开启 → 尝试加急
509
- try:
510
- ok = await self._card_service.send_urgent_app(
511
- tracker.last_notify_message_id, [uid]
512
- )
513
- if ok:
514
- # 加急成功 → 15 秒后自动取消
515
- asyncio.create_task(self._cancel_urgent_later(
516
- tracker.last_notify_message_id, [uid], delay=5
517
- ))
518
- else:
519
- # 加急失败(权限未开通等)→ 降级发新消息
520
- label = ""
521
- text = f'<at user_id="{uid}">{label}</at> {cli_name} 已就绪,等待您的输入...(这是第{count}次通知)'
522
- msg_id = await self._card_service.send_text(tracker.chat_id, text)
523
- if msg_id:
524
- tracker.last_notify_message_id = msg_id
525
- except Exception as e:
526
- logger.warning(f"加急通知失败: {e}")
527
- else:
528
- # 首次通知(或无法加急时)→ 发新消息,记录 message_id
529
- label = "所有人" if uid == "all" else ""
530
- text = f'<at user_id="{uid}">{label}</at> {cli_name} 已就绪,等待您的输入...(这是第{count}次通知)'
531
- try:
521
+ async def _send_ready_notification(
522
+ self, tracker: StreamTracker, cli_type: str = "claude"
523
+ ) -> None:
524
+ """发送就绪通知(加急或新消息),应在卡片操作完成后调用"""
525
+ count = _increment_ready_count()
526
+ uid = tracker.notify_user_id or "all"
527
+ cli_name = "Claude" if cli_type == "claude" else "Codex"
528
+ logger.info(f"就绪提醒: chat_id={tracker.chat_id[:8]}..., count={count}, uid={uid}, "
529
+ f"last_msg={'有' if tracker.last_notify_message_id else '无'}")
530
+
531
+ if tracker.last_notify_message_id and uid != "all" and _urgent_enabled:
532
+ # 已有通知消息 + 加急开关开启 → 尝试加急
533
+ try:
534
+ ok = await self._card_service.send_urgent_app(
535
+ tracker.last_notify_message_id, [uid]
536
+ )
537
+ if ok:
538
+ # 加急成功 → 5 秒后自动取消
539
+ asyncio.create_task(self._cancel_urgent_later(
540
+ tracker.last_notify_message_id, [uid], delay=5
541
+ ))
542
+ else:
543
+ # 加急失败(权限未开通等)→ 降级发新消息
544
+ label = ""
545
+ text = f'<at user_id="{uid}">{label}</at> {cli_name} 已就绪,等待您的输入...(这是第{count}次通知)'
532
546
  msg_id = await self._card_service.send_text(tracker.chat_id, text)
533
547
  if msg_id:
534
548
  tracker.last_notify_message_id = msg_id
535
- except Exception as e:
536
- logger.warning(f"就绪提醒发送失败: {e}")
549
+ except Exception as e:
550
+ logger.warning(f"加急通知失败: {e}")
551
+ else:
552
+ # 首次通知(或无法加急时)→ 发新消息,记录 message_id
553
+ label = "所有人" if uid == "all" else ""
554
+ text = f'<at user_id="{uid}">{label}</at> {cli_name} 已就绪,等待您的输入...(这是第{count}次通知)'
555
+ try:
556
+ msg_id = await self._card_service.send_text(tracker.chat_id, text)
557
+ if msg_id:
558
+ tracker.last_notify_message_id = msg_id
559
+ except Exception as e:
560
+ logger.warning(f"就绪提醒发送失败: {e}")
537
561
 
538
562
  async def _cancel_urgent_later(self, message_id: str, user_ids: list, delay: float = 15) -> None:
539
563
  """延迟取消加急通知"""
@@ -597,10 +621,11 @@ class SharedMemoryPoller:
597
621
 
598
622
  # ── 模块级辅助函数 ────────────────────────────────────────────────────────────
599
623
 
600
- def _is_ready(blocks: list, status_line: Optional[dict], option_block: Optional[dict]) -> bool:
601
- """数据层就绪判断:无 streaming block、无 status_line(option_block 不影响就绪)"""
624
+ def _is_ready(blocks: list, status_line: Optional[dict], option_block: Optional[dict], agent_panel: Optional[dict] = None) -> bool:
625
+ """数据层就绪判断:无 streaming block、无 status_line、无后台 agent(option_block 不影响就绪)"""
602
626
  has_streaming = any(b.get("is_streaming", False) for b in blocks)
603
- return not has_streaming and status_line is None
627
+ has_agents = agent_panel is not None
628
+ return not has_streaming and status_line is None and not has_agents
604
629
 
605
630
 
606
631
  _READY_COUNT_FILE = USER_DATA_DIR / "ready_notify_count"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "remote-claude",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "双端共享 Claude CLI 工具",
5
5
  "bin": {
6
6
  "remote-claude": "bin/remote-claude",
package/remote_claude.py CHANGED
@@ -252,6 +252,44 @@ def cmd_status(args):
252
252
  return 0
253
253
 
254
254
 
255
+ WATCHDOG_SCRIPT = USER_DATA_DIR / "watchdog.sh"
256
+ WATCHDOG_PID_FILE = USER_DATA_DIR / "watchdog.pid"
257
+
258
+
259
+ def _start_watchdog():
260
+ """启动后台 watchdog(如果尚未运行)"""
261
+ if not WATCHDOG_SCRIPT.exists():
262
+ return # 脚本不存在时静默跳过
263
+ # 检查是否已在运行
264
+ if WATCHDOG_PID_FILE.exists():
265
+ try:
266
+ pid = int(WATCHDOG_PID_FILE.read_text().strip())
267
+ os.kill(pid, 0)
268
+ return # 已在运行
269
+ except (ProcessLookupError, ValueError, OSError):
270
+ pass
271
+ process = subprocess.Popen(
272
+ ["bash", str(WATCHDOG_SCRIPT)],
273
+ stdout=subprocess.DEVNULL, # watchdog 通过 tee 自己写 $LOG,不需要 stdout 捕获
274
+ stderr=subprocess.DEVNULL,
275
+ start_new_session=True,
276
+ )
277
+ print(f" watchdog: 已启动 (PID: {process.pid})")
278
+
279
+
280
+ def _stop_watchdog():
281
+ """停止后台 watchdog"""
282
+ if not WATCHDOG_PID_FILE.exists():
283
+ return
284
+ try:
285
+ pid = int(WATCHDOG_PID_FILE.read_text().strip())
286
+ os.kill(pid, signal.SIGTERM)
287
+ print(f" watchdog: 已停止 (PID: {pid})")
288
+ except (ProcessLookupError, ValueError, OSError):
289
+ pass
290
+ WATCHDOG_PID_FILE.unlink(missing_ok=True)
291
+
292
+
255
293
  def cmd_lark_start(args):
256
294
  """启动飞书客户端(守护进程)"""
257
295
  if is_lark_running():
@@ -296,6 +334,7 @@ def cmd_lark_start(args):
296
334
  print(f" 日志: {log_file}")
297
335
  print(f"\n使用 'python3 remote_claude.py lark status' 查看状态")
298
336
  print(f"使用 'python3 remote_claude.py lark stop' 停止")
337
+ _start_watchdog()
299
338
  return 0
300
339
  else:
301
340
  print("✗ 启动失败,请查看日志:")
@@ -342,6 +381,7 @@ def cmd_lark_stop(args):
342
381
  if not is_lark_running():
343
382
  print("✓ 飞书客户端已停止")
344
383
  cleanup_lark()
384
+ _stop_watchdog()
345
385
  return 0
346
386
  else:
347
387
  print("✗ 无法停止进程,请手动终止:")
@@ -351,6 +391,7 @@ def cmd_lark_stop(args):
351
391
  except ProcessLookupError:
352
392
  print("进程已不存在,清理残留文件")
353
393
  cleanup_lark()
394
+ _stop_watchdog()
354
395
  return 0
355
396
  except Exception as e:
356
397
  print(f"✗ 停止失败: {e}")
@@ -477,6 +518,198 @@ def cmd_update(args):
477
518
  return 0
478
519
 
479
520
 
521
+ def cmd_deps(args):
522
+ """检查并安装依赖"""
523
+ import shutil
524
+
525
+ YELLOW = "\033[33m"
526
+ GREEN = "\033[32m"
527
+ RED = "\033[31m"
528
+ RESET = "\033[0m"
529
+
530
+ def print_ok(msg):
531
+ print(f"{GREEN}✓{RESET} {msg}")
532
+
533
+ def print_warn(msg):
534
+ print(f"{YELLOW}⚠{RESET} {msg}")
535
+
536
+ def print_err(msg):
537
+ print(f"{RED}✗{RESET} {msg}")
538
+
539
+ print(f"\n{GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{RESET}")
540
+ print(f"{GREEN} 依赖检查{RESET}")
541
+ print(f"{GREEN}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━{RESET}\n")
542
+
543
+ # 检查 uv
544
+ uv_path = shutil.which("uv")
545
+ if uv_path:
546
+ r = subprocess.run(["uv", "--version"], capture_output=True, text=True)
547
+ print_ok(f"uv: {r.stdout.strip()}")
548
+ else:
549
+ print_err("uv: 未安装")
550
+
551
+ # 检查 claude CLI
552
+ claude_path = shutil.which("claude")
553
+ if claude_path:
554
+ print_ok("Claude CLI: 已安装")
555
+ else:
556
+ print_warn("Claude CLI: 未安装")
557
+
558
+ # 检查 codex CLI
559
+ codex_path = shutil.which("codex")
560
+ if codex_path:
561
+ print_ok("Codex CLI: 已安装")
562
+ else:
563
+ print_warn("Codex CLI: 未安装(可选)")
564
+
565
+ # 检查 tmux
566
+ REQUIRED_MAJOR = 3
567
+ REQUIRED_MINOR = 6
568
+
569
+ tmux_path = shutil.which("tmux")
570
+ tmux_ok = False
571
+ if tmux_path:
572
+ r = subprocess.run(["tmux", "-V"], capture_output=True, text=True)
573
+ ver_str = r.stdout.strip().split()[-1] if r.stdout.strip() else "0.0"
574
+ parts = ver_str.replace("a", "").replace("b", "").replace("c", "").split(".")
575
+ try:
576
+ major = int(parts[0])
577
+ minor = int(parts[1]) if len(parts) > 1 else 0
578
+ except ValueError:
579
+ major, minor = 0, 0
580
+ if major > REQUIRED_MAJOR or (major == REQUIRED_MAJOR and minor >= REQUIRED_MINOR):
581
+ print_ok(f"tmux: {r.stdout.strip()}(满足 >= {REQUIRED_MAJOR}.{REQUIRED_MINOR})")
582
+ tmux_ok = True
583
+ else:
584
+ print_warn(f"tmux: {r.stdout.strip()}(需要 >= {REQUIRED_MAJOR}.{REQUIRED_MINOR})")
585
+ else:
586
+ print_err("tmux: 未安装")
587
+
588
+ if tmux_ok:
589
+ print(f"\n{GREEN}所有关键依赖已满足。{RESET}")
590
+ return 0
591
+
592
+ # tmux 版本不满足,提供源码编译安装
593
+ print(f"\n{YELLOW}tmux 版本不满足要求,是否从源码编译安装 tmux 3.6a?{RESET}")
594
+ print(f" 安装位置: $HOME/.local(不需要 root 权限)")
595
+ print(f" 编译依赖安装可能需要 sudo 密码\n")
596
+
597
+ try:
598
+ answer = input("继续?[y/N]: ").strip().lower()
599
+ except (EOFError, KeyboardInterrupt):
600
+ print()
601
+ return 0
602
+
603
+ if answer not in ("y", "yes"):
604
+ print("已跳过 tmux 安装。")
605
+ return 0
606
+
607
+ # 安装编译依赖
608
+ print(f"\n{YELLOW}[1/4] 安装编译依赖...{RESET}")
609
+ os_name = os.uname().sysname
610
+ if os_name == "Darwin":
611
+ subprocess.run(["brew", "install", "libevent", "ncurses", "pkg-config", "bison"], check=False)
612
+ elif os_name == "Linux":
613
+ if shutil.which("apt-get"):
614
+ subprocess.run(["sudo", "apt-get", "update"], check=False)
615
+ subprocess.run(["sudo", "apt-get", "install", "-y",
616
+ "build-essential", "libevent-dev", "libncurses5-dev",
617
+ "libncursesw5-dev", "bison", "pkg-config"], check=False)
618
+ elif shutil.which("yum"):
619
+ subprocess.run(["sudo", "yum", "groupinstall", "-y", "Development Tools"], check=False)
620
+ subprocess.run(["sudo", "yum", "install", "-y",
621
+ "libevent-devel", "ncurses-devel", "bison"], check=False)
622
+ else:
623
+ print_warn("无法识别包管理器,请手动安装编译依赖: libevent-dev ncurses-dev bison pkg-config")
624
+
625
+ # 下载源码
626
+ print(f"\n{YELLOW}[2/4] 下载 tmux 3.6a 源码...{RESET}")
627
+ import tempfile
628
+ tmpdir = tempfile.mkdtemp()
629
+ tarball = os.path.join(tmpdir, "tmux.tar.gz")
630
+ tmux_url = "https://github.com/tmux/tmux/releases/download/3.6a/tmux-3.6a.tar.gz"
631
+
632
+ r = subprocess.run(["curl", "-fsSL", tmux_url, "-o", tarball])
633
+ if r.returncode != 0:
634
+ print_err("下载失败,请检查网络连接。")
635
+ return 1
636
+
637
+ subprocess.run(["tar", "-xzf", tarball, "-C", tmpdir], check=True)
638
+ src_dir = os.path.join(tmpdir, "tmux-3.6a")
639
+
640
+ # 编译
641
+ prefix = os.path.join(os.path.expanduser("~"), ".local")
642
+ print(f"\n{YELLOW}[3/4] 编译 tmux(安装到 {prefix})...{RESET}")
643
+
644
+ nproc = "2"
645
+ try:
646
+ r = subprocess.run(["nproc"], capture_output=True, text=True)
647
+ if r.returncode == 0:
648
+ nproc = r.stdout.strip()
649
+ except FileNotFoundError:
650
+ pass
651
+
652
+ r = subprocess.run(
653
+ f"./configure --prefix={prefix} && make -j{nproc} && make install",
654
+ shell=True, cwd=src_dir
655
+ )
656
+ if r.returncode != 0:
657
+ print_err("编译失败,请检查编译依赖是否已安装。")
658
+ return 1
659
+
660
+ # 清理临时目录
661
+ import shutil as _shutil
662
+ _shutil.rmtree(tmpdir, ignore_errors=True)
663
+
664
+ # 配置 PATH
665
+ print(f"\n{YELLOW}[4/4] 配置 PATH...{RESET}")
666
+ local_bin = os.path.join(prefix, "bin")
667
+ os.environ["PATH"] = f"{local_bin}:{os.environ.get('PATH', '')}"
668
+
669
+ if f"{local_bin}" not in os.environ.get("PATH", ""):
670
+ os.environ["PATH"] = f"{local_bin}:{os.environ['PATH']}"
671
+
672
+ # 写入 shell rc
673
+ shell_name = os.path.basename(os.environ.get("SHELL", "bash"))
674
+ rc_file = os.path.join(os.path.expanduser("~"), ".zshrc" if shell_name == "zsh" else ".bashrc")
675
+ path_line = 'export PATH="$HOME/.local/bin:$PATH"'
676
+ try:
677
+ rc_content = open(rc_file).read() if os.path.exists(rc_file) else ""
678
+ if "$HOME/.local/bin" not in rc_content:
679
+ with open(rc_file, "a") as f:
680
+ f.write(f"\n# remote-claude: tmux 路径\n{path_line}\n")
681
+ print_ok(f"已将 $HOME/.local/bin 写入 {rc_file}")
682
+ except Exception as e:
683
+ print_warn(f"无法写入 {rc_file}: {e}")
684
+
685
+ # 验证
686
+ tmux_bin = os.path.join(local_bin, "tmux")
687
+ if os.path.exists(tmux_bin):
688
+ r = subprocess.run([tmux_bin, "-V"], capture_output=True, text=True)
689
+ print(f"\n{GREEN}✓ tmux 安装成功: {r.stdout.strip()}{RESET}")
690
+ print(f" 路径: {tmux_bin}")
691
+ print(f" 请运行 source {rc_file} 或重新打开终端使 PATH 生效。")
692
+ else:
693
+ print_err("安装似乎未成功,请检查上方输出。")
694
+ return 1
695
+
696
+ return 0
697
+
698
+
699
+ def cmd_lark_init(args):
700
+ """飞书机器人配置向导(扫码自动创建应用)"""
701
+ from lark_client.setup_wizard import SetupWizard
702
+ check_only = getattr(args, "check", False)
703
+ new_only = getattr(args, "new", False)
704
+ wizard = SetupWizard(check_only=check_only, new_only=new_only)
705
+ rc = wizard.run()
706
+ # 配置成功后自动重启飞书客户端(--check / --new 模式跳过)
707
+ if rc == 0 and not check_only and not new_only:
708
+ print("\n正在重启飞书客户端以应用新配置...")
709
+ cmd_lark_restart(args)
710
+ return rc
711
+
712
+
480
713
  def cmd_lark(args):
481
714
  """飞书客户端管理(兼容旧命令)"""
482
715
  # 如果没有子命令,默认显示状态或启动
@@ -485,6 +718,7 @@ def cmd_lark(args):
485
718
  else:
486
719
  print("飞书客户端未运行")
487
720
  print("\n可用命令:")
721
+ print(" python3 remote_claude.py lark init - 配置向导(首次使用)")
488
722
  print(" python3 remote_claude.py lark start - 启动客户端")
489
723
  print(" python3 remote_claude.py lark stop - 停止客户端")
490
724
  print(" python3 remote_claude.py lark restart - 重启客户端")
@@ -506,6 +740,9 @@ def main():
506
740
  %(prog)s status mywork 显示 mywork 会话状态
507
741
 
508
742
  飞书客户端:
743
+ %(prog)s lark init 配置向导(首次使用,扫码自动创建应用)
744
+ %(prog)s lark init --check 检查当前配置状态
745
+ %(prog)s lark init --new 扫码创建新应用(不修改已有配置)
509
746
  %(prog)s lark start 启动飞书客户端
510
747
  %(prog)s lark stop 停止飞书客户端
511
748
  %(prog)s lark restart 重启飞书客户端
@@ -529,6 +766,9 @@ def main():
529
766
 
530
767
  更新:
531
768
  %(prog)s update 更新到最新版本
769
+
770
+ 依赖管理:
771
+ %(prog)s deps 检查依赖并安装(含 tmux 源码编译)
532
772
  """
533
773
  )
534
774
 
@@ -605,6 +845,13 @@ def main():
605
845
  lark_status_parser = lark_subparsers.add_parser("status", help="查看飞书客户端状态")
606
846
  lark_status_parser.set_defaults(func=cmd_lark_status)
607
847
 
848
+ # lark init
849
+ lark_init_parser = lark_subparsers.add_parser("init", help="配置向导(扫码自动创建应用)")
850
+ lark_init_group = lark_init_parser.add_mutually_exclusive_group()
851
+ lark_init_group.add_argument("--check", action="store_true", help="仅检查当前配置状态")
852
+ lark_init_group.add_argument("--new", action="store_true", help="扫码创建新应用(不修改已有配置)")
853
+ lark_init_parser.set_defaults(func=cmd_lark_init)
854
+
608
855
  # 如果只输入 lark 没有子命令,使用默认处理
609
856
  lark_parser.set_defaults(func=cmd_lark)
610
857
 
@@ -636,6 +883,10 @@ def main():
636
883
  update_parser = subparsers.add_parser("update", help="更新 remote-claude 到最新版本")
637
884
  update_parser.set_defaults(func=cmd_update)
638
885
 
886
+ # deps 命令
887
+ deps_parser = subparsers.add_parser("deps", help="检查并安装依赖(tmux 源码编译等)")
888
+ deps_parser.set_defaults(func=cmd_deps)
889
+
639
890
  args, remaining = parser.parse_known_args()
640
891
 
641
892
  if args.command is None: