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.
- package/Makefile +8 -2
- package/README.md +40 -56
- package/VERSION +1 -1
- package/bin/board +102 -34
- package/bin/cnb +59 -33
- package/bin/dispatcher +25 -11
- package/bin/doctor +3 -5
- package/bin/init +8 -8
- package/bin/notify +224 -0
- package/bin/registry +8 -23
- 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 +18 -6
- package/lib/board_lock.py +5 -0
- package/lib/board_mailbox.py +6 -4
- package/lib/board_msg.py +112 -24
- package/lib/board_pending.py +233 -0
- package/lib/board_pulse.py +14 -0
- package/lib/board_task.py +22 -10
- package/lib/board_tui.py +28 -20
- package/lib/board_view.py +60 -28
- 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 +4 -1
- package/lib/concerns/coral.py +1 -1
- package/lib/concerns/digest_scheduler.py +109 -0
- package/lib/concerns/file_watcher.py +73 -68
- package/lib/concerns/health.py +1 -1
- package/lib/concerns/notification_push.py +171 -0
- package/lib/concerns/notifications.py +58 -3
- 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/monitor.py +8 -4
- package/lib/notification_config.py +101 -0
- package/lib/swarm.py +43 -35
- package/lib/swarm_backend.py +63 -29
- 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/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 .
|
|
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,
|
|
17
|
+
def __init__(self, cfg: DispatcherConfig, nudge: NudgeCoordinator) -> None:
|
|
18
18
|
super().__init__()
|
|
19
19
|
self.cfg = cfg
|
|
20
|
-
self.
|
|
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
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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.
|
|
132
|
+
self.nudge.check_session(name, now)
|
package/lib/concerns/health.py
CHANGED
|
@@ -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}:
|
|
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
|
-
|
|
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\
|
|
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:])
|