claude-nb 0.3.0 → 0.5.1

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.
Files changed (65) hide show
  1. package/Makefile +8 -2
  2. package/README.md +57 -36
  3. package/VERSION +1 -1
  4. package/bin/board +112 -34
  5. package/bin/cnb +152 -65
  6. package/bin/dispatcher +25 -11
  7. package/bin/doctor +3 -5
  8. package/bin/init +13 -47
  9. package/bin/notify +224 -0
  10. package/bin/registry +8 -23
  11. package/bin/swarm +41 -860
  12. package/bin/sync-version +131 -0
  13. package/lib/board_admin.py +19 -9
  14. package/lib/board_bbs.py +23 -8
  15. package/lib/board_bug.py +2 -1
  16. package/lib/board_db.py +31 -141
  17. package/lib/board_lock.py +5 -1
  18. package/lib/board_mailbox.py +18 -8
  19. package/lib/board_maintenance.py +26 -27
  20. package/lib/board_msg.py +76 -39
  21. package/lib/board_pending.py +233 -0
  22. package/lib/board_pulse.py +14 -0
  23. package/lib/board_task.py +41 -32
  24. package/lib/board_tui.py +120 -0
  25. package/lib/board_view.py +70 -50
  26. package/lib/board_vote.py +9 -3
  27. package/lib/build_lock.py +7 -7
  28. package/lib/common.py +45 -3
  29. package/lib/concerns/__init__.py +7 -11
  30. package/lib/concerns/{coral_manager.py → coral.py} +54 -4
  31. package/lib/concerns/digest_scheduler.py +109 -0
  32. package/lib/concerns/file_watcher.py +73 -68
  33. package/lib/concerns/health.py +136 -0
  34. package/lib/concerns/helpers.py +1 -5
  35. package/lib/concerns/idle.py +130 -0
  36. package/lib/concerns/notification_push.py +171 -0
  37. package/lib/concerns/notifications.py +145 -0
  38. package/lib/concerns/nudge_coordinator.py +148 -0
  39. package/lib/digest.py +62 -0
  40. package/lib/health.py +2 -2
  41. package/lib/inject.py +2 -2
  42. package/lib/migrate.py +1 -0
  43. package/lib/monitor.py +9 -22
  44. package/lib/notification_config.py +101 -0
  45. package/lib/swarm.py +464 -0
  46. package/lib/swarm_backend.py +300 -0
  47. package/lib/theme_profiles.py +89 -0
  48. package/migrations/004_heartbeat.sql +1 -0
  49. package/migrations/005_notification_log.sql +12 -0
  50. package/migrations/006_pending_actions.sql +15 -0
  51. package/package.json +4 -3
  52. package/pyproject.toml +3 -2
  53. package/registry/README.md +9 -0
  54. package/registry/pubkeys.json +2 -1
  55. package/schema.sql +29 -1
  56. package/lib/concerns/bug_sla_checker.py +0 -32
  57. package/lib/concerns/coral_poker.py +0 -57
  58. package/lib/concerns/health_checker.py +0 -72
  59. package/lib/concerns/idle_detector.py +0 -56
  60. package/lib/concerns/idle_killer.py +0 -41
  61. package/lib/concerns/idle_nudger.py +0 -38
  62. package/lib/concerns/inbox_nudger.py +0 -34
  63. package/lib/concerns/resource_monitor.py +0 -47
  64. package/lib/concerns/session_keepalive.py +0 -23
  65. package/lib/concerns/time_announcer.py +0 -34
@@ -0,0 +1,109 @@
1
+ """DigestScheduler — sends daily/weekly digests at scheduled times."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime
6
+ from pathlib import Path
7
+
8
+ from lib.digest import generate_daily_digest
9
+ from lib.notification_config import load as load_config
10
+
11
+ from .base import Concern
12
+ from .config import DispatcherConfig
13
+ from .helpers import board_send, db, get_dev_sessions, log, warn
14
+
15
+
16
+ class DigestScheduler(Concern):
17
+ interval = 30
18
+
19
+ def __init__(self, cfg: DispatcherConfig) -> None:
20
+ super().__init__()
21
+ self.cfg = cfg
22
+ self._last_daily_date: str = ""
23
+ self._last_weekly_date: str = ""
24
+
25
+ def _config_path(self) -> Path:
26
+ return self.cfg.claudes_dir / "notifications.toml"
27
+
28
+ def _already_sent_today(self, notif_type: str, date_str: str) -> bool:
29
+ try:
30
+ count = db(self.cfg).scalar(
31
+ "SELECT COUNT(*) FROM notification_log WHERE notif_type=? AND ref_id=?",
32
+ (notif_type, f"digest-{date_str}"),
33
+ )
34
+ return bool(count)
35
+ except Exception:
36
+ return False
37
+
38
+ def _record_digest(self, notif_type: str, recipient: str, date_str: str, channel: str) -> None:
39
+ try:
40
+ with db(self.cfg).conn() as c:
41
+ c.execute(
42
+ "INSERT INTO notification_log(notif_type, recipient, ref_type, ref_id, channel) VALUES(?,?,?,?,?)",
43
+ (notif_type, recipient, "digest", f"digest-{date_str}", channel),
44
+ )
45
+ except Exception as e:
46
+ warn(f"digest record: {e}")
47
+
48
+ def tick(self, now: int) -> None:
49
+ d = datetime.now()
50
+ if d.hour != 9 or d.minute > 5:
51
+ return
52
+
53
+ date_str = d.strftime("%Y-%m-%d")
54
+
55
+ if date_str != self._last_daily_date:
56
+ self._send_daily(date_str)
57
+ self._last_daily_date = date_str
58
+
59
+ if d.weekday() == 0 and date_str != self._last_weekly_date:
60
+ self._send_weekly(date_str)
61
+ self._last_weekly_date = date_str
62
+
63
+ def _send_daily(self, date_str: str) -> None:
64
+ if self._already_sent_today("daily-digest", date_str):
65
+ log(f"Daily digest already sent for {date_str}, skipping")
66
+ return
67
+
68
+ config = load_config(self._config_path())
69
+ members = get_dev_sessions(self.cfg)
70
+ subscribers = config.subscribers_for("daily-digest", members)
71
+
72
+ if not subscribers:
73
+ return
74
+
75
+ try:
76
+ board = db(self.cfg)
77
+ digest_text = generate_daily_digest(board)
78
+ except Exception as e:
79
+ warn(f"digest generation failed: {e}")
80
+ return
81
+
82
+ for member in subscribers:
83
+ channel = config.channel_for(member)
84
+ if channel == "board-inbox":
85
+ board_send(self.cfg, member, digest_text)
86
+ else:
87
+ log(f"[digest] {channel} delivery not implemented for {member}")
88
+ self._record_digest("daily-digest", member, date_str, channel)
89
+
90
+ log(f"Daily digest sent to {len(subscribers)} subscribers")
91
+
92
+ def _send_weekly(self, date_str: str) -> None:
93
+ if self._already_sent_today("weekly-report", date_str):
94
+ log(f"Weekly report already sent for {date_str}, skipping")
95
+ return
96
+
97
+ config = load_config(self._config_path())
98
+ members = get_dev_sessions(self.cfg)
99
+ subscribers = config.subscribers_for("weekly-report", members)
100
+
101
+ if not subscribers:
102
+ return
103
+
104
+ board_send(self.cfg, "all", f"[Weekly Report] {date_str} — 本周报告待实现")
105
+ for member in subscribers:
106
+ channel = config.channel_for(member)
107
+ self._record_digest("weekly-report", member, date_str, channel)
108
+
109
+ log(f"Weekly report sent to {len(subscribers)} subscribers")
@@ -8,16 +8,16 @@ from lib.common import is_suspended
8
8
  from .base import Concern
9
9
  from .config import DispatcherConfig
10
10
  from .helpers import log
11
- from .inbox_nudger import InboxNudger
11
+ from .nudge_coordinator import NudgeCoordinator
12
12
 
13
13
 
14
14
  class FileWatcher(Concern):
15
15
  interval = 1
16
16
 
17
- def __init__(self, cfg: DispatcherConfig, inbox: InboxNudger) -> None:
17
+ def __init__(self, cfg: DispatcherConfig, nudge: NudgeCoordinator) -> None:
18
18
  super().__init__()
19
19
  self.cfg = cfg
20
- self.inbox = inbox
20
+ self.nudge = nudge
21
21
  self._queue: list[str] = []
22
22
  self._lock = threading.Lock()
23
23
  self._stop = threading.Event()
@@ -41,77 +41,82 @@ class FileWatcher(Concern):
41
41
  import select as sel
42
42
 
43
43
  kq = sel.kqueue()
44
- watch_dir = str(self.cfg.sessions_dir)
45
- dir_fd = os.open(watch_dir, os.O_RDONLY)
46
- kq.control(
47
- [
48
- sel.kevent(
49
- dir_fd,
50
- filter=sel.KQ_FILTER_VNODE,
51
- flags=sel.KQ_EV_ADD | sel.KQ_EV_CLEAR,
52
- fflags=sel.KQ_NOTE_WRITE,
53
- )
54
- ],
55
- 0,
56
- )
44
+ dir_fd = -1
57
45
  file_fds: dict[str, int] = {}
46
+ try:
47
+ watch_dir = str(self.cfg.sessions_dir)
48
+ dir_fd = os.open(watch_dir, os.O_RDONLY)
49
+ kq.control(
50
+ [
51
+ sel.kevent(
52
+ dir_fd,
53
+ filter=sel.KQ_FILTER_VNODE,
54
+ flags=sel.KQ_EV_ADD | sel.KQ_EV_CLEAR,
55
+ fflags=sel.KQ_NOTE_WRITE,
56
+ )
57
+ ],
58
+ 0,
59
+ )
58
60
 
59
- def refresh() -> None:
60
- for f in os.listdir(watch_dir):
61
- if not f.endswith(".md"):
61
+ def refresh() -> None:
62
+ for f in os.listdir(watch_dir):
63
+ if not f.endswith(".md"):
64
+ continue
65
+ path = os.path.join(watch_dir, f)
66
+ if path in file_fds:
67
+ continue
68
+ fd = -1
69
+ try:
70
+ fd = os.open(path, os.O_RDONLY)
71
+ kq.control(
72
+ [
73
+ sel.kevent(
74
+ fd,
75
+ filter=sel.KQ_FILTER_VNODE,
76
+ flags=sel.KQ_EV_ADD | sel.KQ_EV_CLEAR,
77
+ fflags=sel.KQ_NOTE_WRITE | sel.KQ_NOTE_EXTEND,
78
+ )
79
+ ],
80
+ 0,
81
+ )
82
+ file_fds[path] = fd
83
+ except OSError:
84
+ if fd >= 0:
85
+ os.close(fd)
86
+
87
+ refresh()
88
+ while not self._stop.is_set():
89
+ fd_to_path = {fd: p for p, fd in file_fds.items()}
90
+ try:
91
+ events = kq.control(None, 8, 2.0)
92
+ except (InterruptedError, OSError):
93
+ if self._stop.is_set():
94
+ break
62
95
  continue
63
- path = os.path.join(watch_dir, f)
64
- if path in file_fds:
96
+ if not events:
97
+ refresh()
65
98
  continue
99
+ changed: set[str] = set()
100
+ for ev in events:
101
+ if ev.ident == dir_fd:
102
+ refresh()
103
+ elif ev.ident in fd_to_path:
104
+ changed.add(os.path.basename(fd_to_path[ev.ident]).replace(".md", ""))
105
+ if changed:
106
+ with self._lock:
107
+ self._queue.extend(changed)
108
+ finally:
109
+ for fd in file_fds.values():
66
110
  try:
67
- fd = os.open(path, os.O_RDONLY)
68
- kq.control(
69
- [
70
- sel.kevent(
71
- fd,
72
- filter=sel.KQ_FILTER_VNODE,
73
- flags=sel.KQ_EV_ADD | sel.KQ_EV_CLEAR,
74
- fflags=sel.KQ_NOTE_WRITE | sel.KQ_NOTE_EXTEND,
75
- )
76
- ],
77
- 0,
78
- )
79
- file_fds[path] = fd
111
+ os.close(fd)
80
112
  except OSError:
81
113
  pass
82
-
83
- refresh()
84
- while not self._stop.is_set():
85
- fd_to_path = {fd: p for p, fd in file_fds.items()}
86
- try:
87
- events = kq.control(None, 8, 2.0)
88
- except (InterruptedError, OSError):
89
- if self._stop.is_set():
90
- break
91
- continue
92
- if not events:
93
- refresh()
94
- continue
95
- changed: set[str] = set()
96
- for ev in events:
97
- if ev.ident == dir_fd:
98
- refresh()
99
- elif ev.ident in fd_to_path:
100
- changed.add(os.path.basename(fd_to_path[ev.ident]).replace(".md", ""))
101
- if changed:
102
- with self._lock:
103
- self._queue.extend(changed)
104
-
105
- for fd in file_fds.values():
106
- try:
107
- os.close(fd)
108
- except OSError:
109
- pass
110
- try:
111
- os.close(dir_fd)
112
- except OSError:
113
- pass
114
- kq.close()
114
+ if dir_fd >= 0:
115
+ try:
116
+ os.close(dir_fd)
117
+ except OSError:
118
+ pass
119
+ kq.close()
115
120
 
116
121
  def stop(self) -> None:
117
122
  self._stop.set()
@@ -124,4 +129,4 @@ class FileWatcher(Concern):
124
129
  self._queue.clear()
125
130
  for name in names:
126
131
  if not is_suspended(name, self.cfg.suspended_file):
127
- self.inbox.nudge_if_unread(name)
132
+ self.nudge.check_session(name, now)
@@ -0,0 +1,136 @@
1
+ """Health monitoring: session keepalive, periodic health checks, resource monitoring."""
2
+
3
+ import time
4
+
5
+ from lib.common import date_to_epoch, is_suspended
6
+ from lib.resources import check_battery, check_cpu, check_memory
7
+
8
+ from .base import Concern
9
+ from .config import DispatcherConfig
10
+ from .coral import CoralManager, CoralPoker
11
+ from .helpers import (
12
+ board_send,
13
+ db,
14
+ get_dev_sessions,
15
+ is_claude_running,
16
+ log,
17
+ tmux_ok,
18
+ tmux_send,
19
+ )
20
+
21
+
22
+ class SessionKeepAlive(Concern):
23
+ interval = 5
24
+
25
+ def __init__(self, cfg: DispatcherConfig) -> None:
26
+ super().__init__()
27
+ self.cfg = cfg
28
+
29
+ def tick(self, now: int) -> None:
30
+ for name in get_dev_sessions(self.cfg):
31
+ if is_suspended(name, self.cfg.suspended_file):
32
+ continue
33
+ sess = f"{self.cfg.prefix}-{name}"
34
+ if tmux_ok("has-session", "-t", sess) and not is_claude_running(sess):
35
+ log(f"{name}: 同学已退出, NOT restarting (idle policy)")
36
+
37
+
38
+ class HealthChecker(Concern):
39
+ INITIAL = 600
40
+ MAX = 3600
41
+ IDLE_THRESHOLD = 1800
42
+
43
+ def __init__(self, cfg: DispatcherConfig, poker: CoralPoker, coral: CoralManager) -> None:
44
+ super().__init__()
45
+ self.cfg = cfg
46
+ self.interval = self.INITIAL
47
+ self.poker = poker
48
+ self.coral = coral
49
+ self.last_idle_alert: int = 0
50
+
51
+ def tick(self, now: int) -> None:
52
+ parts = []
53
+ for name in get_dev_sessions(self.cfg):
54
+ on = "on" if tmux_ok("has-session", "-t", f"{self.cfg.prefix}-{name}") else "off"
55
+ parts.append(f"{name}:{on}")
56
+ status = " ".join(parts)
57
+
58
+ log(f"Health check (interval:{self.interval}s): {status}")
59
+ self.poker.poke(f"[Dispatcher] 健康巡检 {time.strftime('%H:%M:%S')}: {status}")
60
+ self.interval = min(self.interval * 2, self.MAX)
61
+ self._check_team_idle(now)
62
+
63
+ def _check_team_idle(self, now: int) -> None:
64
+ if not self.cfg.board_db.exists():
65
+ return
66
+ idle_list: list[str] = []
67
+ total = 0
68
+ d = db(self.cfg)
69
+
70
+ for name in get_dev_sessions(self.cfg):
71
+ if is_suspended(name, self.cfg.suspended_file):
72
+ continue
73
+ sess = f"{self.cfg.prefix}-{name}"
74
+ if not tmux_ok("has-session", "-t", sess) or not is_claude_running(sess):
75
+ continue
76
+ if self.coral.in_grace_period(name, now):
77
+ continue
78
+
79
+ total += 1
80
+ try:
81
+ updated = d.scalar("SELECT updated_at FROM sessions WHERE name=?", (name,)) or ""
82
+ except Exception:
83
+ continue
84
+ if updated:
85
+ age = now - date_to_epoch(updated)
86
+ if age > self.IDLE_THRESHOLD:
87
+ idle_list.append(f"{name}({age}s)")
88
+
89
+ if idle_list and len(idle_list) == total and (now - self.last_idle_alert) > 3600:
90
+ log(f"All sessions idle: {' '.join(idle_list)}")
91
+ board_send(
92
+ self.cfg,
93
+ "lead",
94
+ f"[Dispatcher] 全员空闲超过 {self.IDLE_THRESHOLD // 60} 分钟:{' '.join(idle_list)}。可能需要分配工作。",
95
+ )
96
+ self.last_idle_alert = now
97
+
98
+
99
+ class ResourceMonitor(Concern):
100
+ interval = 60
101
+
102
+ def __init__(self, cfg: DispatcherConfig) -> None:
103
+ super().__init__()
104
+ self.cfg = cfg
105
+ self.last_state = ""
106
+
107
+ def tick(self, now: int) -> None:
108
+ batt = check_battery()
109
+ mem = check_memory()
110
+ cpu = check_cpu()
111
+
112
+ state = f"{batt.status}|{batt.pct}|{mem.status}|{mem.pressure}|{cpu.status}|{cpu.usage}"
113
+ if state == self.last_state:
114
+ return
115
+ self.last_state = state
116
+
117
+ if batt.status == "CRITICAL":
118
+ log(f"RESOURCE: Battery CRITICAL ({batt.pct}%)")
119
+ board_send(self.cfg, "All", f"[Resource] 电池严重不足 ({batt.pct}%),暂停非关键 session。")
120
+ for name in get_dev_sessions(self.cfg):
121
+ sess = f"{self.cfg.prefix}-{name}"
122
+ if is_claude_running(sess):
123
+ tmux_send(sess, "[系统] 电池严重不足,请立即保存状态。")
124
+ elif batt.status == "LOW":
125
+ log(f"RESOURCE: Battery LOW ({batt.pct}%)")
126
+ board_send(self.cfg, "All", f"[Resource] 电池低 ({batt.pct}%),建议减少活跃 session 到 2-3 个。")
127
+
128
+ if mem.status == "CRITICAL":
129
+ log("RESOURCE: Memory pressure CRITICAL")
130
+ board_send(self.cfg, "All", "[Resource] 内存压力严重!建议重启最大的 session 释放内存。")
131
+ elif mem.status == "WARNING":
132
+ log("RESOURCE: Memory pressure WARNING")
133
+ board_send(self.cfg, "All", "[Resource] 内存压力升高,关注中。")
134
+
135
+ if cpu.status == "SATURATED":
136
+ log(f"RESOURCE: CPU saturated ({cpu.usage}%)")
@@ -70,11 +70,7 @@ def get_dev_sessions(cfg: DispatcherConfig) -> list[str]:
70
70
  return []
71
71
  pfx = f"{cfg.prefix}-"
72
72
  protected = {"dispatcher", "lead"}
73
- return [
74
- line[len(pfx) :]
75
- for line in raw.splitlines()
76
- if line.startswith(pfx) and line[len(pfx) :] not in protected
77
- ]
73
+ return [line[len(pfx) :] for line in raw.splitlines() if line.startswith(pfx) and line[len(pfx) :] not in protected]
78
74
 
79
75
 
80
76
  def pane_md5(sess: str) -> str:
@@ -0,0 +1,130 @@
1
+ """Idle detection, nudging, and killing for dispatcher sessions."""
2
+
3
+ import re
4
+
5
+ from lib.common import is_suspended
6
+
7
+ from .base import Concern
8
+ from .config import DispatcherConfig
9
+ from .coral import CoralManager
10
+ from .helpers import (
11
+ board_send,
12
+ get_dev_sessions,
13
+ has_tool_process,
14
+ is_claude_running,
15
+ log,
16
+ pane_md5,
17
+ tmux,
18
+ tmux_ok,
19
+ tmux_send,
20
+ )
21
+
22
+
23
+ class IdleDetector(Concern):
24
+ interval = 5
25
+
26
+ def __init__(self, cfg: DispatcherConfig) -> None:
27
+ super().__init__()
28
+ self.cfg = cfg
29
+ self.cache: dict[str, str] = {} # sess -> "idle" | "busy"
30
+ self._prev_snap: dict[str, str] = {} # sess -> md5 from previous tick
31
+
32
+ def is_idle(self, sess: str) -> bool:
33
+ return self.cache.get(sess) == "idle"
34
+
35
+ def tick(self, now: int) -> None:
36
+ self.cache.clear()
37
+ raw = tmux("list-sessions", "-F", "#{session_name}")
38
+ if not raw:
39
+ self._prev_snap.clear()
40
+ return
41
+
42
+ all_sessions = [s for s in raw.splitlines() if s.startswith(f"{self.cfg.prefix}-")]
43
+ need_snapshot: list[str] = []
44
+
45
+ for sess in all_sessions:
46
+ pane = tmux("capture-pane", "-t", sess, "-p") or ""
47
+ prompts = [l for l in pane.splitlines() if l.startswith("❯")]
48
+ if prompts and re.match(r"^❯ .{3,}", prompts[-1]):
49
+ self.cache[sess] = "busy"
50
+ continue
51
+ if has_tool_process(sess):
52
+ self.cache[sess] = "busy"
53
+ continue
54
+ need_snapshot.append(sess)
55
+
56
+ current_snap: dict[str, str] = {}
57
+ for sess in need_snapshot:
58
+ md5 = pane_md5(sess)
59
+ current_snap[sess] = md5
60
+ if sess in self._prev_snap and self._prev_snap[sess] == md5:
61
+ self.cache[sess] = "idle"
62
+ else:
63
+ self.cache[sess] = "busy"
64
+
65
+ self._prev_snap = {s: pane_md5(s) for s in all_sessions if s not in self.cache or self.cache[s] != "busy"}
66
+ self._prev_snap.update(current_snap)
67
+
68
+
69
+ class IdleKiller(Concern):
70
+ interval = 5
71
+ THRESHOLD = 1800
72
+
73
+ def __init__(self, cfg: DispatcherConfig, idle: IdleDetector, coral: CoralManager) -> None:
74
+ super().__init__()
75
+ self.cfg = cfg
76
+ self.idle = idle
77
+ self.coral = coral
78
+ self.idle_since: dict[str, int] = {}
79
+
80
+ def tick(self, now: int) -> None:
81
+ for name in get_dev_sessions(self.cfg):
82
+ sess = f"{self.cfg.prefix}-{name}"
83
+ if not tmux_ok("has-session", "-t", sess) or not is_claude_running(sess):
84
+ self.idle_since.pop(name, None)
85
+ continue
86
+
87
+ if name not in self.coral.boot_times:
88
+ self.coral.record_boot(name)
89
+ if self.coral.in_grace_period(name, now):
90
+ continue
91
+
92
+ if self.idle.is_idle(sess):
93
+ since = self.idle_since.setdefault(name, now)
94
+ if (now - since) >= self.THRESHOLD:
95
+ log(f"{name}: idle {now - since}s (>30min), killing session")
96
+ tmux("kill-session", "-t", sess)
97
+ self.idle_since.pop(name, None)
98
+ board_send(self.cfg, "All", f"[Dispatcher] {name} 闲置超过 30 分钟,已终止。")
99
+ else:
100
+ self.idle_since.pop(name, None)
101
+
102
+
103
+ class IdleNudger(Concern):
104
+ interval = 5
105
+ COOLDOWN = 300
106
+
107
+ def __init__(self, cfg: DispatcherConfig, idle: IdleDetector) -> None:
108
+ super().__init__()
109
+ self.cfg = cfg
110
+ self.idle = idle
111
+ self.last_nudge: dict[str, int] = {}
112
+
113
+ def tick(self, now: int) -> None:
114
+ for name in get_dev_sessions(self.cfg):
115
+ if is_suspended(name, self.cfg.suspended_file):
116
+ continue
117
+ sess = f"{self.cfg.prefix}-{name}"
118
+ if not tmux_ok("has-session", "-t", sess) or not is_claude_running(sess):
119
+ continue
120
+ if not self.idle.is_idle(sess):
121
+ continue
122
+ if (now - self.last_nudge.get(name, 0)) < self.COOLDOWN:
123
+ continue
124
+
125
+ log(f"{name}: idle, nudging autonomous loop")
126
+ tmux_send(
127
+ sess,
128
+ f"继续工作。检查你的 OKR ({self.cfg.okr_dir}/{name}.md),推进你的活跃 KR。自己决定优先级。",
129
+ )
130
+ self.last_nudge[name] = now