claude-nb 0.4.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 (49) hide show
  1. package/Makefile +8 -2
  2. package/README.md +40 -56
  3. package/VERSION +1 -1
  4. package/bin/board +102 -34
  5. package/bin/cnb +59 -33
  6. package/bin/dispatcher +25 -11
  7. package/bin/doctor +3 -5
  8. package/bin/init +8 -8
  9. package/bin/notify +224 -0
  10. package/bin/registry +8 -23
  11. package/bin/sync-version +131 -0
  12. package/lib/board_admin.py +19 -9
  13. package/lib/board_bbs.py +23 -8
  14. package/lib/board_bug.py +2 -1
  15. package/lib/board_db.py +18 -6
  16. package/lib/board_lock.py +5 -0
  17. package/lib/board_mailbox.py +6 -4
  18. package/lib/board_msg.py +112 -24
  19. package/lib/board_pending.py +233 -0
  20. package/lib/board_pulse.py +14 -0
  21. package/lib/board_task.py +22 -10
  22. package/lib/board_tui.py +28 -20
  23. package/lib/board_view.py +60 -28
  24. package/lib/board_vote.py +9 -3
  25. package/lib/build_lock.py +7 -7
  26. package/lib/common.py +45 -3
  27. package/lib/concerns/__init__.py +4 -1
  28. package/lib/concerns/coral.py +1 -1
  29. package/lib/concerns/digest_scheduler.py +109 -0
  30. package/lib/concerns/file_watcher.py +73 -68
  31. package/lib/concerns/health.py +1 -1
  32. package/lib/concerns/notification_push.py +171 -0
  33. package/lib/concerns/notifications.py +58 -3
  34. package/lib/concerns/nudge_coordinator.py +148 -0
  35. package/lib/digest.py +62 -0
  36. package/lib/health.py +2 -2
  37. package/lib/inject.py +2 -2
  38. package/lib/monitor.py +8 -4
  39. package/lib/notification_config.py +101 -0
  40. package/lib/swarm.py +43 -35
  41. package/lib/swarm_backend.py +63 -29
  42. package/lib/theme_profiles.py +89 -0
  43. package/migrations/004_heartbeat.sql +1 -0
  44. package/migrations/005_notification_log.sql +12 -0
  45. package/migrations/006_pending_actions.sql +15 -0
  46. package/package.json +4 -3
  47. package/pyproject.toml +3 -2
  48. package/registry/README.md +9 -0
  49. package/schema.sql +29 -1
@@ -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 .notifications 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)
@@ -32,7 +32,7 @@ class SessionKeepAlive(Concern):
32
32
  continue
33
33
  sess = f"{self.cfg.prefix}-{name}"
34
34
  if tmux_ok("has-session", "-t", sess) and not is_claude_running(sess):
35
- log(f"{name}: agent exited, NOT restarting (idle policy)")
35
+ log(f"{name}: 同学已退出, NOT restarting (idle policy)")
36
36
 
37
37
 
38
38
  class HealthChecker(Concern):
@@ -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)
@@ -1,11 +1,11 @@
1
- """Notifications: inbox nudging, time announcements, bug SLA checks."""
1
+ """Notifications: inbox nudging, time announcements, bug SLA checks, queued message flushing."""
2
2
 
3
3
  import subprocess
4
4
 
5
5
  from .base import Concern
6
6
  from .config import DispatcherConfig
7
7
  from .coral import CoralPoker
8
- from .helpers import board_send, db, get_dev_sessions, is_claude_running, log, tmux_ok, tmux_send
8
+ from .helpers import board_send, db, get_dev_sessions, is_claude_running, log, tmux, tmux_ok, tmux_send
9
9
 
10
10
 
11
11
  class InboxNudger(Concern):
@@ -43,7 +43,20 @@ class TimeAnnouncer(Concern):
43
43
  def __init__(self, cfg: DispatcherConfig) -> None:
44
44
  super().__init__()
45
45
  self.cfg = cfg
46
- self.last_hour = -1
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
47
60
 
48
61
  def tick(self, now: int) -> None:
49
62
  from datetime import datetime as dt
@@ -54,6 +67,10 @@ class TimeAnnouncer(Concern):
54
67
  self.last_hour = d.hour
55
68
  ts = d.strftime("%Y-%m-%d %H:%M")
56
69
 
70
+ if self._already_sent(ts):
71
+ log(f"Hourly announcement: {d.hour}:00 (already sent, skipping)")
72
+ return
73
+
57
74
  if d.hour == 9:
58
75
  board_send(
59
76
  self.cfg,
@@ -66,6 +83,44 @@ class TimeAnnouncer(Concern):
66
83
  log(f"Hourly announcement: {d.hour}:00")
67
84
 
68
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
+
69
124
  class BugSLAChecker(Concern):
70
125
  interval = 600
71
126
 
@@ -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:])