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,171 @@
1
+ """NotificationPushConcern — realtime notification delivery for mentions and issue activity."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ import time
7
+ from pathlib import Path
8
+
9
+ from lib.notification_config import NotificationConfig
10
+ from lib.notification_config import load as load_config
11
+
12
+ from .base import Concern
13
+ from .config import DispatcherConfig
14
+ from .helpers import board_send, db, get_dev_sessions, log, warn
15
+
16
+ MENTION_RE = re.compile(r"(?<![a-zA-Z0-9.])@([a-z][a-z0-9_-]*)", re.IGNORECASE)
17
+
18
+
19
+ class NotificationPushConcern(Concern):
20
+ interval = 10
21
+
22
+ def __init__(self, cfg: DispatcherConfig) -> None:
23
+ super().__init__()
24
+ self.cfg = cfg
25
+ self._config: NotificationConfig | None = None
26
+ self._config_mtime: float = 0
27
+ self._last_msg_id: int = 0
28
+ self._last_bug_check: str = ""
29
+ self._init_watermarks()
30
+
31
+ def _config_path(self) -> Path:
32
+ return self.cfg.claudes_dir / "notifications.toml"
33
+
34
+ def _load_config(self) -> NotificationConfig:
35
+ path = self._config_path()
36
+ try:
37
+ mtime = path.stat().st_mtime if path.exists() else 0
38
+ except OSError:
39
+ mtime = 0
40
+ if self._config is None or mtime != self._config_mtime:
41
+ self._config = load_config(path)
42
+ self._config_mtime = mtime
43
+ return self._config
44
+
45
+ def _init_watermarks(self) -> None:
46
+ try:
47
+ d = db(self.cfg)
48
+ row = d.query_one("SELECT MAX(id) FROM messages")
49
+ self._last_msg_id = (row[0] or 0) if row else 0
50
+ self._last_bug_check = time.strftime("%Y-%m-%d %H:%M:%S")
51
+ except Exception:
52
+ pass
53
+
54
+ def _already_sent(self, notif_type: str, recipient: str, ref_id: str) -> bool:
55
+ try:
56
+ count = db(self.cfg).scalar(
57
+ "SELECT COUNT(*) FROM notification_log WHERE notif_type=? AND recipient=? AND ref_id=?",
58
+ (notif_type, recipient, ref_id),
59
+ )
60
+ return bool(count)
61
+ except Exception:
62
+ return False
63
+
64
+ def _record(self, notif_type: str, recipient: str, ref_type: str, ref_id: str, channel: str) -> None:
65
+ try:
66
+ with db(self.cfg).conn() as c:
67
+ c.execute(
68
+ "INSERT INTO notification_log(notif_type, recipient, ref_type, ref_id, channel) VALUES(?,?,?,?,?)",
69
+ (notif_type, recipient, ref_type, ref_id, channel),
70
+ )
71
+ except Exception as e:
72
+ warn(f"notification_log insert: {e}")
73
+
74
+ def _deliver(self, config: NotificationConfig, recipient: str, notif_type: str, message: str, ref_id: str) -> None:
75
+ channel = config.channel_for(recipient)
76
+ if channel == "board-inbox":
77
+ board_send(self.cfg, recipient, message)
78
+ else:
79
+ log(f"[notify] {channel} delivery not yet implemented for {recipient}: {message[:60]}")
80
+ self._record(notif_type, recipient, "message" if notif_type == "mention" else "bug", ref_id, channel)
81
+
82
+ def _scan_mentions(self, config: NotificationConfig) -> None:
83
+ try:
84
+ rows = db(self.cfg).query(
85
+ "SELECT id, sender, recipient, body FROM messages WHERE id > ?",
86
+ (self._last_msg_id,),
87
+ )
88
+ except Exception:
89
+ return
90
+ if not rows:
91
+ return
92
+
93
+ members = [s for s in get_dev_sessions(self.cfg)]
94
+ max_id = self._last_msg_id
95
+
96
+ for row in rows:
97
+ msg_id, sender, _recipient, body = row[0], row[1], row[2], row[3]
98
+ if msg_id > max_id:
99
+ max_id = msg_id
100
+ mentioned = set(m.lower() for m in MENTION_RE.findall(body))
101
+ for name in mentioned:
102
+ if name == sender.lower():
103
+ continue
104
+ if not config.is_subscribed(name, "mention"):
105
+ continue
106
+ if name not in members and name != "human":
107
+ continue
108
+ ref = f"msg-{msg_id}"
109
+ if self._already_sent("mention", name, ref):
110
+ continue
111
+ preview = body[:80].replace("\n", " ")
112
+ self._deliver(config, name, "mention", f"[通知] {sender} 提到了你: {preview}", ref)
113
+
114
+ self._last_msg_id = max_id
115
+
116
+ def _scan_bugs(self, config: NotificationConfig) -> None:
117
+ try:
118
+ rows = db(self.cfg).query(
119
+ "SELECT id, severity, reporter, assignee, status, description, reported_at FROM bugs WHERE reported_at > ?",
120
+ (self._last_bug_check,),
121
+ )
122
+ except Exception:
123
+ return
124
+
125
+ members = [s for s in get_dev_sessions(self.cfg)]
126
+ now_ts = time.strftime("%Y-%m-%d %H:%M:%S")
127
+
128
+ for row in rows:
129
+ bug_id, severity, reporter, assignee, _status, desc, _reported = (
130
+ row[0],
131
+ row[1],
132
+ row[2],
133
+ row[3],
134
+ row[4],
135
+ row[5],
136
+ row[6],
137
+ )
138
+ ref = f"bug-{bug_id}"
139
+
140
+ for name in members:
141
+ if not config.is_subscribed(name, "issue-activity"):
142
+ continue
143
+ if self._already_sent("issue-activity", name, ref):
144
+ continue
145
+ preview = desc[:60].replace("\n", " ")
146
+ self._deliver(
147
+ config,
148
+ name,
149
+ "issue-activity",
150
+ f"[Bug {severity}] {reporter} 报告: {preview}",
151
+ ref,
152
+ )
153
+
154
+ if assignee and assignee.lower() in [m.lower() for m in members]:
155
+ target = assignee.lower()
156
+ assign_ref = f"bug-assign-{bug_id}"
157
+ if not self._already_sent("mention", target, assign_ref):
158
+ self._deliver(
159
+ config,
160
+ target,
161
+ "mention",
162
+ f"[通知] 你被指派了 Bug {bug_id} ({severity}): {desc[:60]}",
163
+ assign_ref,
164
+ )
165
+
166
+ self._last_bug_check = now_ts
167
+
168
+ def tick(self, now: int) -> None:
169
+ config = self._load_config()
170
+ self._scan_mentions(config)
171
+ self._scan_bugs(config)
@@ -0,0 +1,145 @@
1
+ """Notifications: inbox nudging, time announcements, bug SLA checks, queued message flushing."""
2
+
3
+ import subprocess
4
+
5
+ from .base import Concern
6
+ from .config import DispatcherConfig
7
+ from .coral import CoralPoker
8
+ from .helpers import board_send, db, get_dev_sessions, is_claude_running, log, tmux, tmux_ok, tmux_send
9
+
10
+
11
+ class InboxNudger(Concern):
12
+ interval = 5
13
+
14
+ def __init__(self, cfg: DispatcherConfig) -> None:
15
+ super().__init__()
16
+ self.cfg = cfg
17
+
18
+ def nudge_if_unread(self, name: str) -> None:
19
+ if not self.cfg.board_db.exists():
20
+ return
21
+ try:
22
+ unread = db(self.cfg).scalar("SELECT COUNT(*) FROM inbox WHERE session=? AND read=0", (name,)) or 0
23
+ except Exception:
24
+ return
25
+ if unread <= 0:
26
+ return
27
+
28
+ sess = f"{self.cfg.prefix}-{name}"
29
+ if not tmux_ok("has-session", "-t", sess) or not is_claude_running(sess):
30
+ return
31
+
32
+ log(f"INBOX: {name} has {unread} unread -> nudging")
33
+ tmux_send(sess, f"./board --as {name} inbox")
34
+
35
+ def tick(self, now: int) -> None:
36
+ for name in get_dev_sessions(self.cfg):
37
+ self.nudge_if_unread(name)
38
+
39
+
40
+ class TimeAnnouncer(Concern):
41
+ interval = 30
42
+
43
+ def __init__(self, cfg: DispatcherConfig) -> None:
44
+ super().__init__()
45
+ self.cfg = cfg
46
+ from datetime import datetime as dt
47
+
48
+ self.last_hour = dt.now().hour
49
+
50
+ def _already_sent(self, hour_ts: str) -> bool:
51
+ """Check DB for a clock message already sent this hour (prevents duplicate announcements)."""
52
+ try:
53
+ count = db(self.cfg).scalar(
54
+ "SELECT COUNT(*) FROM messages WHERE sender='dispatcher' AND body LIKE '%[Clock]%' AND ts LIKE ?",
55
+ (f"{hour_ts}%",),
56
+ )
57
+ return bool(count)
58
+ except Exception:
59
+ return False
60
+
61
+ def tick(self, now: int) -> None:
62
+ from datetime import datetime as dt
63
+
64
+ d = dt.now()
65
+ if d.minute != 0 or d.hour == self.last_hour:
66
+ return
67
+ self.last_hour = d.hour
68
+ ts = d.strftime("%Y-%m-%d %H:%M")
69
+
70
+ if self._already_sent(ts):
71
+ log(f"Hourly announcement: {d.hour}:00 (already sent, skipping)")
72
+ return
73
+
74
+ if d.hour == 9:
75
+ board_send(
76
+ self.cfg,
77
+ "All",
78
+ f"[Clock] {ts} ({d.strftime('%A')}) — 新一天。检查 KR 列表,确认优先级。",
79
+ )
80
+ log("Daily announcement sent")
81
+ else:
82
+ board_send(self.cfg, "All", f"[Clock] 现在是 {ts}。")
83
+ log(f"Hourly announcement: {d.hour}:00")
84
+
85
+
86
+ class QueuedMessageFlusher(Concern):
87
+ """Detect queued messages in idle agent panes and send Enter to flush them.
88
+
89
+ When nudge injects a command while Claude Code is busy, it gets queued.
90
+ The pane shows 'queued message' and waits for Enter. This concern
91
+ auto-flushes that queue when the agent becomes idle.
92
+ """
93
+
94
+ interval = 5
95
+ COOLDOWN = 30
96
+
97
+ def __init__(self, cfg: DispatcherConfig) -> None:
98
+ super().__init__()
99
+ self.cfg = cfg
100
+ self.last_flush: dict[str, int] = {}
101
+
102
+ def tick(self, now: int) -> None:
103
+ for name in get_dev_sessions(self.cfg):
104
+ sess = f"{self.cfg.prefix}-{name}"
105
+ if not tmux_ok("has-session", "-t", sess) or not is_claude_running(sess):
106
+ continue
107
+ if (now - self.last_flush.get(name, 0)) < self.COOLDOWN:
108
+ continue
109
+
110
+ content = tmux("capture-pane", "-t", sess, "-p") or ""
111
+ if "queued message" not in content.lower():
112
+ continue
113
+
114
+ lines = content.splitlines()[-5:]
115
+ has_empty_prompt = any(line.rstrip() == "❯" for line in lines)
116
+ if not has_empty_prompt:
117
+ continue
118
+
119
+ log(f"{name}: flushing queued message")
120
+ subprocess.run(["tmux", "send-keys", "-t", sess, "Enter"], capture_output=True, timeout=5)
121
+ self.last_flush[name] = now
122
+
123
+
124
+ class BugSLAChecker(Concern):
125
+ interval = 600
126
+
127
+ def __init__(self, cfg: DispatcherConfig, poker: CoralPoker) -> None:
128
+ super().__init__()
129
+ self.cfg = cfg
130
+ self.poker = poker
131
+
132
+ def tick(self, now: int) -> None:
133
+ try:
134
+ r = subprocess.run(
135
+ [self.cfg.board_sh, "bug", "overdue"],
136
+ capture_output=True,
137
+ text=True,
138
+ timeout=10,
139
+ )
140
+ overdue = r.stdout.strip()
141
+ except Exception:
142
+ return
143
+ if overdue and "No overdue" not in overdue:
144
+ log(f"Bug SLA alert: {overdue}")
145
+ self.poker.poke(f"[Dispatcher] Bug SLA 超时: {overdue}")
@@ -0,0 +1,148 @@
1
+ """NudgeCoordinator — unified nudge orchestrator replacing InboxNudger, QueuedMessageFlusher, IdleNudger.
2
+
3
+ Consolidates all nudge decisions into a single Concern with:
4
+ - per-session cooldown across all nudge types
5
+ - nudge-type priority: inbox > queued_flush > idle
6
+ - post-nudge effectiveness tracking with backoff
7
+ - cached session status checks (one tmux call per session per tick)
8
+ """
9
+
10
+ from dataclasses import dataclass
11
+
12
+ from lib.common import is_suspended
13
+
14
+ from .base import Concern
15
+ from .config import DispatcherConfig
16
+ from .helpers import db, get_dev_sessions, is_claude_running, log, tmux, tmux_ok, tmux_send
17
+
18
+
19
+ @dataclass
20
+ class NudgeRecord:
21
+ time: int = 0
22
+ nudge_type: str = ""
23
+ consecutive_ineffective: int = 0
24
+
25
+
26
+ class NudgeCoordinator(Concern):
27
+ interval = 5
28
+ COOLDOWN = 15
29
+ MAX_BACKOFF_MULTIPLIER = 8
30
+
31
+ def __init__(self, cfg: DispatcherConfig, idle) -> None:
32
+ super().__init__()
33
+ self.cfg = cfg
34
+ self.idle = idle
35
+ self._records: dict[str, NudgeRecord] = {}
36
+ self._session_ok: dict[str, bool] = {}
37
+ self._cache_tick: int = 0
38
+
39
+ def _session_ready(self, name: str, now: int) -> bool:
40
+ if now != self._cache_tick:
41
+ self._session_ok.clear()
42
+ self._cache_tick = now
43
+ if name not in self._session_ok:
44
+ sess = f"{self.cfg.prefix}-{name}"
45
+ self._session_ok[name] = tmux_ok("has-session", "-t", sess) and is_claude_running(sess)
46
+ return self._session_ok[name]
47
+
48
+ def _effective_cooldown(self, name: str) -> int:
49
+ rec = self._records.get(name)
50
+ if not rec or rec.consecutive_ineffective <= 1:
51
+ return self.COOLDOWN
52
+ backoff_exp = min(rec.consecutive_ineffective - 1, 3)
53
+ return int(self.COOLDOWN * min(2**backoff_exp, self.MAX_BACKOFF_MULTIPLIER))
54
+
55
+ def _can_nudge(self, name: str, now: int) -> bool:
56
+ rec = self._records.get(name)
57
+ if not rec:
58
+ return True
59
+ return (now - rec.time) >= self._effective_cooldown(name)
60
+
61
+ def _check_effectiveness(self, name: str) -> None:
62
+ rec = self._records.get(name)
63
+ if not rec:
64
+ return
65
+ sess = f"{self.cfg.prefix}-{name}"
66
+ if self.idle.is_idle(sess):
67
+ rec.consecutive_ineffective += 1
68
+ else:
69
+ rec.consecutive_ineffective = 0
70
+
71
+ def _record(self, name: str, nudge_type: str, now: int) -> None:
72
+ rec = self._records.get(name)
73
+ old_ineffective = rec.consecutive_ineffective if rec else 0
74
+ self._records[name] = NudgeRecord(time=now, nudge_type=nudge_type, consecutive_ineffective=old_ineffective)
75
+ log(f"NUDGE [{nudge_type}] {name}")
76
+
77
+ def _try_inbox(self, name: str) -> bool:
78
+ if not self.cfg.board_db.exists():
79
+ return False
80
+ try:
81
+ unread = db(self.cfg).scalar("SELECT COUNT(*) FROM inbox WHERE session=? AND read=0", (name,)) or 0
82
+ except Exception:
83
+ return False
84
+ if unread <= 0:
85
+ return False
86
+ sess = f"{self.cfg.prefix}-{name}"
87
+ tmux_send(sess, f"./board --as {name} inbox")
88
+ return True
89
+
90
+ def _try_queued_flush(self, name: str) -> bool:
91
+ sess = f"{self.cfg.prefix}-{name}"
92
+ content = tmux("capture-pane", "-t", sess, "-p") or ""
93
+ if "queued message" not in content.lower():
94
+ return False
95
+ lines = content.splitlines()[-5:]
96
+ if not any(line.rstrip() == "❯" for line in lines):
97
+ return False
98
+ tmux_send(sess, "")
99
+ return True
100
+
101
+ def _try_idle(self, name: str) -> bool:
102
+ sess = f"{self.cfg.prefix}-{name}"
103
+ if not self.idle.is_idle(sess):
104
+ return False
105
+ tmux_send(
106
+ sess,
107
+ f"继续工作。检查你的 OKR ({self.cfg.okr_dir}/{name}.md),推进你的活跃 KR。自己决定优先级。",
108
+ )
109
+ return True
110
+
111
+ def get_nudge_stats(self, name: str) -> dict:
112
+ rec = self._records.get(name)
113
+ if not rec:
114
+ return {"consecutive_ineffective": 0, "last_nudge_type": "", "last_nudge_time": 0}
115
+ return {
116
+ "consecutive_ineffective": rec.consecutive_ineffective,
117
+ "last_nudge_type": rec.nudge_type,
118
+ "last_nudge_time": rec.time,
119
+ }
120
+
121
+ def _process_session(self, name: str, now: int) -> None:
122
+ if is_suspended(name, self.cfg.suspended_file):
123
+ return
124
+ if not self._session_ready(name, now):
125
+ return
126
+
127
+ if name in self._records:
128
+ self._check_effectiveness(name)
129
+
130
+ if not self._can_nudge(name, now):
131
+ return
132
+
133
+ for nudge_type, try_fn in [
134
+ ("inbox", self._try_inbox),
135
+ ("flush", self._try_queued_flush),
136
+ ("idle", self._try_idle),
137
+ ]:
138
+ if try_fn(name):
139
+ self._record(name, nudge_type, now)
140
+ break
141
+
142
+ def check_session(self, name: str, now: int) -> None:
143
+ """Check and nudge a specific session (used by FileWatcher for instant inbox detection)."""
144
+ self._process_session(name, now)
145
+
146
+ def tick(self, now: int) -> None:
147
+ for name in get_dev_sessions(self.cfg):
148
+ self._process_session(name, now)
package/lib/digest.py ADDED
@@ -0,0 +1,62 @@
1
+ """digest — generate daily/weekly activity summaries from board data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from datetime import datetime, timedelta
6
+
7
+ from lib.board_db import BoardDB
8
+
9
+
10
+ def generate_daily_digest(board: BoardDB, since: datetime | None = None) -> str:
11
+ if since is None:
12
+ since = datetime.now() - timedelta(hours=24)
13
+ since_str = since.strftime("%Y-%m-%d %H:%M:%S")
14
+
15
+ sections: list[str] = []
16
+
17
+ msg_count = board.scalar("SELECT COUNT(*) FROM messages WHERE ts > ?", (since_str,)) or 0
18
+ if msg_count:
19
+ top_senders = board.query(
20
+ "SELECT sender, COUNT(*) as cnt FROM messages WHERE ts > ? GROUP BY sender ORDER BY cnt DESC LIMIT 5",
21
+ (since_str,),
22
+ )
23
+ sender_str = ", ".join(f"{r[0]}({r[1]})" for r in (top_senders or []))
24
+ sections.append(f"消息: {msg_count} 条 — {sender_str}")
25
+
26
+ bugs_opened = board.query(
27
+ "SELECT id, severity, reporter, description FROM bugs WHERE reported_at > ? AND status='OPEN'",
28
+ (since_str,),
29
+ )
30
+ if bugs_opened:
31
+ bug_lines = [f" {r[0]} [{r[1]}] {r[3][:40]}" for r in bugs_opened]
32
+ sections.append(f"新 Bug: {len(bugs_opened)} 个\n" + "\n".join(bug_lines))
33
+
34
+ bugs_fixed = board.query(
35
+ "SELECT id, severity, assignee FROM bugs WHERE fixed_at > ?",
36
+ (since_str,),
37
+ )
38
+ if bugs_fixed:
39
+ fix_lines = [f" {r[0]} [{r[1]}] by {r[2]}" for r in bugs_fixed]
40
+ sections.append(f"修复: {len(bugs_fixed)} 个\n" + "\n".join(fix_lines))
41
+
42
+ tasks_done = board.query(
43
+ "SELECT session, description FROM tasks WHERE done_at > ? AND status='done'",
44
+ (since_str,),
45
+ )
46
+ if tasks_done:
47
+ task_lines = [f" {r[0]}: {r[1][:50]}" for r in tasks_done]
48
+ sections.append(f"完成任务: {len(tasks_done)} 个\n" + "\n".join(task_lines))
49
+
50
+ kudos_list = board.query(
51
+ "SELECT sender, target, reason FROM kudos WHERE ts > ?",
52
+ (since_str,),
53
+ )
54
+ if kudos_list:
55
+ kudos_lines = [f" {r[0]} → {r[1]}: {r[2][:40]}" for r in kudos_list]
56
+ sections.append(f"Kudos: {len(kudos_list)} 个\n" + "\n".join(kudos_lines))
57
+
58
+ if not sections:
59
+ return "[Daily Digest] 过去 24h 无活动。"
60
+
61
+ header = f"[Daily Digest] {datetime.now().strftime('%Y-%m-%d')} 活动摘要"
62
+ return header + "\n" + "\n".join(sections)
package/lib/health.py CHANGED
@@ -110,8 +110,8 @@ def print_health_report() -> None:
110
110
  sessions = get_sessions(prefix)
111
111
 
112
112
  print()
113
- print(" Session\t\tStatus\t\tRestarts\tUptime\t\tAgent")
114
- print(" -------\t\t------\t\t--------\t------\t\t-----")
113
+ print(" Session\t\tStatus\t\tRestarts\tUptime\t\tEngine")
114
+ print(" -------\t\t------\t\t--------\t------\t\t------")
115
115
 
116
116
  total = 0
117
117
  alive = 0
package/lib/inject.py CHANGED
@@ -129,7 +129,7 @@ def inject(target: str, message: str, prefix: str | None = None, sessions: list
129
129
  mode = detect_mode(prefix)
130
130
  if mode == "none":
131
131
  print("ERROR: neither tmux nor screen found", file=sys.stderr)
132
- sys.exit(1)
132
+ raise SystemExit(1)
133
133
 
134
134
  send_fn = send_tmux if mode == "tmux" else send_screen
135
135
  target_lower = target.lower()
@@ -153,7 +153,7 @@ def main() -> None:
153
153
  ' ./inject.py alice "what\'s blocking P0?"\n'
154
154
  ' ./inject.py all "everyone check inbox"',
155
155
  )
156
- sys.exit(1)
156
+ raise SystemExit(1)
157
157
 
158
158
  target = sys.argv[1]
159
159
  message = " ".join(sys.argv[2:])
package/lib/migrate.py CHANGED
@@ -83,6 +83,7 @@ def run_migrations(db_path: Path, claudes_home: Path) -> int:
83
83
  # CLI
84
84
  # ---------------------------------------------------------------------------
85
85
 
86
+
86
87
  def main() -> None:
87
88
  """Run pending migrations. Called from init and doctor."""
88
89
  # Resolve CLAUDES_HOME relative to this file
package/lib/monitor.py CHANGED
@@ -75,6 +75,7 @@ class KqueueWatcher:
75
75
  path = os.path.join(self.watch_dir, f)
76
76
  if path in self.file_fds:
77
77
  continue
78
+ fd = -1
78
79
  try:
79
80
  fd = os.open(path, os.O_RDONLY)
80
81
  ev = select.kevent(
@@ -86,7 +87,8 @@ class KqueueWatcher:
86
87
  self.kq.control([ev], 0)
87
88
  self.file_fds[path] = fd
88
89
  except OSError:
89
- pass
90
+ if fd >= 0:
91
+ os.close(fd)
90
92
 
91
93
  def poll(self, timeout: float = 5.0) -> set:
92
94
  """Block up to *timeout* seconds, return set of changed file paths."""
@@ -144,11 +146,13 @@ class InotifyWatcher:
144
146
  import selectors
145
147
 
146
148
  sel = selectors.DefaultSelector()
147
- sel.register(self.proc.stdout, selectors.EVENT_READ)
149
+ stdout = self.proc.stdout
150
+ assert stdout is not None
151
+ sel.register(stdout, selectors.EVENT_READ)
148
152
  changed = set()
149
153
  events = sel.select(timeout=timeout)
150
154
  for key, _ in events:
151
- line = key.fileobj.readline().strip()
155
+ line = key.fileobj.readline().strip() # type: ignore[union-attr]
152
156
  if line:
153
157
  changed.add(line)
154
158
  sel.close()
@@ -231,24 +235,7 @@ def handle_change(file_path: str, env: ClaudesEnv) -> None:
231
235
  pass
232
236
 
233
237
  if unread > 0:
234
- sess = f"{env.prefix}-{name}"
235
- try:
236
- r = subprocess.run(
237
- ["tmux", "has-session", "-t", sess],
238
- capture_output=True,
239
- timeout=5,
240
- )
241
- if r.returncode == 0:
242
- log(f"EVENT: {name} has {unread} unread -- nudging")
243
- subprocess.run(
244
- ["tmux", "send-keys", "-t", sess, "-l", f"./board --as {name} inbox"],
245
- timeout=5,
246
- )
247
- subprocess.run(["tmux", "send-keys", "-t", sess, "Enter"], timeout=5)
248
- else:
249
- log(f"EVENT: {name} has {unread} unread -- session not running")
250
- except Exception:
251
- pass
238
+ log(f"EVENT: {name} has {unread} unread")
252
239
 
253
240
 
254
241
  # ---------------------------------------------------------------------------
@@ -366,7 +353,7 @@ def main() -> None:
366
353
  print(" --benchmark Compare event vs polling")
367
354
  else:
368
355
  print(f"Unknown: {arg}", file=sys.stderr)
369
- sys.exit(1)
356
+ raise SystemExit(1)
370
357
 
371
358
 
372
359
  if __name__ == "__main__":