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.
- package/Makefile +8 -2
- package/README.md +57 -36
- package/VERSION +1 -1
- package/bin/board +112 -34
- package/bin/cnb +152 -65
- package/bin/dispatcher +25 -11
- package/bin/doctor +3 -5
- package/bin/init +13 -47
- package/bin/notify +224 -0
- package/bin/registry +8 -23
- package/bin/swarm +41 -860
- package/bin/sync-version +131 -0
- package/lib/board_admin.py +19 -9
- package/lib/board_bbs.py +23 -8
- package/lib/board_bug.py +2 -1
- package/lib/board_db.py +31 -141
- package/lib/board_lock.py +5 -1
- package/lib/board_mailbox.py +18 -8
- package/lib/board_maintenance.py +26 -27
- package/lib/board_msg.py +76 -39
- package/lib/board_pending.py +233 -0
- package/lib/board_pulse.py +14 -0
- package/lib/board_task.py +41 -32
- package/lib/board_tui.py +120 -0
- package/lib/board_view.py +70 -50
- package/lib/board_vote.py +9 -3
- package/lib/build_lock.py +7 -7
- package/lib/common.py +45 -3
- package/lib/concerns/__init__.py +7 -11
- package/lib/concerns/{coral_manager.py → coral.py} +54 -4
- package/lib/concerns/digest_scheduler.py +109 -0
- package/lib/concerns/file_watcher.py +73 -68
- package/lib/concerns/health.py +136 -0
- package/lib/concerns/helpers.py +1 -5
- package/lib/concerns/idle.py +130 -0
- package/lib/concerns/notification_push.py +171 -0
- package/lib/concerns/notifications.py +145 -0
- package/lib/concerns/nudge_coordinator.py +148 -0
- package/lib/digest.py +62 -0
- package/lib/health.py +2 -2
- package/lib/inject.py +2 -2
- package/lib/migrate.py +1 -0
- package/lib/monitor.py +9 -22
- package/lib/notification_config.py +101 -0
- package/lib/swarm.py +464 -0
- package/lib/swarm_backend.py +300 -0
- package/lib/theme_profiles.py +89 -0
- package/migrations/004_heartbeat.sql +1 -0
- package/migrations/005_notification_log.sql +12 -0
- package/migrations/006_pending_actions.sql +15 -0
- package/package.json +4 -3
- package/pyproject.toml +3 -2
- package/registry/README.md +9 -0
- package/registry/pubkeys.json +2 -1
- package/schema.sql +29 -1
- package/lib/concerns/bug_sla_checker.py +0 -32
- package/lib/concerns/coral_poker.py +0 -57
- package/lib/concerns/health_checker.py +0 -72
- package/lib/concerns/idle_detector.py +0 -56
- package/lib/concerns/idle_killer.py +0 -41
- package/lib/concerns/idle_nudger.py +0 -38
- package/lib/concerns/inbox_nudger.py +0 -34
- package/lib/concerns/resource_monitor.py +0 -47
- package/lib/concerns/session_keepalive.py +0 -23
- 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\
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
356
|
+
raise SystemExit(1)
|
|
370
357
|
|
|
371
358
|
|
|
372
359
|
if __name__ == "__main__":
|