claude-nb 0.3.0 → 0.4.0

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.
@@ -0,0 +1,112 @@
1
+ """board_tui — tmux-native team UI with mouse support.
2
+
3
+ Opens a single terminal window with tmux. Each worker is a window (tab).
4
+ Mouse click on the tab bar to switch. That's it.
5
+ """
6
+
7
+ import os
8
+ import subprocess
9
+ import sys
10
+
11
+ from lib.board_db import BoardDB
12
+
13
+ UI_SESSION = "cnb-ui"
14
+
15
+
16
+ def _tmux(*args: str) -> int:
17
+ return subprocess.run(["tmux", *args], capture_output=True, text=True, timeout=5).returncode
18
+
19
+
20
+ def _tmux_out(*args: str) -> str:
21
+ r = subprocess.run(["tmux", *args], capture_output=True, text=True, timeout=5)
22
+ return r.stdout.strip() if r.returncode == 0 else ""
23
+
24
+
25
+ def _session_exists(name: str) -> bool:
26
+ return _tmux("has-session", "-t", name) == 0
27
+
28
+
29
+ def cmd_tui(db: BoardDB) -> None:
30
+ """Open team UI: one tmux window per worker, mouse-clickable tabs."""
31
+ if not db.env:
32
+ print("ERROR: 需要完整环境才能启动 TUI")
33
+ raise SystemExit(1)
34
+
35
+ prefix = db.env.prefix
36
+ workers = [r[0] for r in db.query("SELECT name FROM sessions WHERE name != 'all' ORDER BY name")]
37
+
38
+ if not workers:
39
+ print("ERROR: 没有注册的 session")
40
+ raise SystemExit(1)
41
+
42
+ online = [w for w in workers if _session_exists(f"{prefix}-{w}")]
43
+ if not online:
44
+ print("ERROR: 没有在线的 worker,先运行 cnb swarm start")
45
+ raise SystemExit(1)
46
+
47
+ # Kill old UI session if exists
48
+ if _session_exists(UI_SESSION):
49
+ _tmux("kill-session", "-t", UI_SESSION)
50
+
51
+ # Create session grouped with first worker
52
+ _tmux("new-session", "-d", "-s", UI_SESSION, "-t", f"{prefix}-{online[0]}")
53
+
54
+ # Link remaining workers as windows
55
+ for name in online[1:]:
56
+ _tmux("link-window", "-s", f"{prefix}-{name}", "-t", UI_SESSION, "-a")
57
+
58
+ # Rename windows
59
+ win_indices = _tmux_out("list-windows", "-t", UI_SESSION, "-F", "#{window_index}").split("\n")
60
+ for i, name in enumerate(online):
61
+ if i < len(win_indices):
62
+ _tmux("rename-window", "-t", f"{UI_SESSION}:{win_indices[i]}", name)
63
+
64
+ # Mouse + clean visual config
65
+ opts = {
66
+ "mouse": "on",
67
+ "status": "on",
68
+ "status-position": "top",
69
+ "status-style": "bg=default,fg=white",
70
+ "status-left": " cnb ",
71
+ "status-left-style": "bold",
72
+ "status-left-length": "6",
73
+ "status-right": f" {len(online)} online ",
74
+ "status-right-style": "dim",
75
+ "window-status-format": " #W ",
76
+ "window-status-current-format": " #W ",
77
+ "window-status-style": "dim",
78
+ "window-status-current-style": "bold,underscore",
79
+ "window-status-separator": "",
80
+ }
81
+ for k, v in opts.items():
82
+ _tmux("set-option", "-t", UI_SESSION, k, v)
83
+
84
+ # Open in new terminal window
85
+ _open_terminal()
86
+
87
+
88
+ def _open_terminal() -> None:
89
+ """Open a new terminal window attached to cnb-ui."""
90
+ attach_cmd = f"tmux attach -t {UI_SESSION}"
91
+
92
+ if sys.platform == "darwin":
93
+ if os.path.isdir("/Applications/iTerm.app"):
94
+ script = (
95
+ 'tell application "iTerm"\n'
96
+ " activate\n"
97
+ f' create window with default profile command "{attach_cmd}"\n'
98
+ "end tell"
99
+ )
100
+ else:
101
+ script = f'tell application "Terminal"\n do script "{attach_cmd}"\n activate\nend tell'
102
+ subprocess.run(["osascript", "-e", script], capture_output=True)
103
+ print("OK 已打开 — 点击顶部 tab 切换同学")
104
+ else:
105
+ import shutil
106
+
107
+ for term in ("gnome-terminal", "xterm", "konsole", "alacritty"):
108
+ if shutil.which(term):
109
+ subprocess.Popen([term, "--", "bash", "-c", attach_cmd])
110
+ print("OK 已打开 — 点击顶部 tab 切换同学")
111
+ return
112
+ print(f"运行: {attach_cmd}")
package/lib/board_view.py CHANGED
@@ -15,6 +15,7 @@ def _git(project_root: Path, *args: str) -> str:
15
15
  ["git", "-C", str(project_root), *args],
16
16
  capture_output=True,
17
17
  text=True,
18
+ timeout=5,
18
19
  )
19
20
  return r.stdout
20
21
 
@@ -40,16 +41,6 @@ def _tmux_pane_command(name: str) -> str:
40
41
  return ""
41
42
 
42
43
 
43
- def _pgrep(pattern: str) -> str | None:
44
- try:
45
- r = subprocess.run(["pgrep", "-f", pattern], capture_output=True, text=True, timeout=3)
46
- if r.returncode == 0:
47
- return r.stdout.strip().split("\n")[0]
48
- except (FileNotFoundError, subprocess.TimeoutExpired):
49
- pass
50
- return None
51
-
52
-
53
44
  def cmd_overview(db: BoardDB) -> None:
54
45
  """Default view when running cnb with no args."""
55
46
  prefix = db.env.prefix
@@ -80,10 +71,7 @@ def cmd_overview(db: BoardDB) -> None:
80
71
  print(line)
81
72
 
82
73
  # ── recent messages ──
83
- rows = db.query(
84
- "SELECT ts, sender, recipient, substr(body, 1, 80) "
85
- "FROM messages ORDER BY id DESC LIMIT 5"
86
- )
74
+ rows = db.query("SELECT ts, sender, recipient, substr(body, 1, 80) FROM messages ORDER BY id DESC LIMIT 5")
87
75
  if rows:
88
76
  print()
89
77
  print("Recent:")
@@ -91,20 +79,20 @@ def cmd_overview(db: BoardDB) -> None:
91
79
  print(f" [{ts_val}] {sender} → {recipient}: {body}")
92
80
 
93
81
  # ── open proposals ──
94
- proposals = db.query(
95
- "SELECT number || '-' || slug FROM proposals WHERE status='OPEN'"
96
- )
82
+ proposals = db.query("SELECT number || '-' || slug FROM proposals WHERE status='OPEN'")
97
83
  if proposals:
98
84
  print()
99
85
  print(f"Open proposals: {len(proposals)}")
100
86
 
101
87
  # ── dispatcher ──
102
- pid = _pgrep("dispatcher")
88
+ dispatcher_sess = f"{prefix}-dispatcher"
103
89
  print()
104
- if pid:
105
- print(f" dispatcher: running (pid {pid})")
90
+ if _tmux_has_session(dispatcher_sess):
91
+ print(f" dispatcher: running ({dispatcher_sess})")
106
92
  else:
107
- running = any(_tmux_has_session(f"{prefix}-{n}") for (n,) in db.query("SELECT name FROM sessions WHERE name != 'all'"))
93
+ running = any(
94
+ _tmux_has_session(f"{prefix}-{n}") for (n,) in db.query("SELECT name FROM sessions WHERE name != 'all'")
95
+ )
108
96
  if running:
109
97
  print(" dispatcher: NOT RUNNING — run: cnb dispatcher")
110
98
  else:
@@ -258,9 +246,9 @@ def cmd_dashboard(db: BoardDB) -> None:
258
246
  print(f" {name:<7s} {status:<8s}{inbox_str}")
259
247
  print(f" {task}")
260
248
  print()
261
- pid = _pgrep("dispatcher")
262
- if pid:
263
- print(f" dispatcher: running (PID {pid})")
249
+ dispatcher_sess = f"{prefix}-dispatcher"
250
+ if _tmux_has_session(dispatcher_sess):
251
+ print(f" dispatcher: running ({dispatcher_sess})")
264
252
  else:
265
253
  print(" dispatcher: NOT RUNNING")
266
254
 
@@ -5,20 +5,13 @@ Each concern is a self-contained module with its own check interval.
5
5
 
6
6
  from .adaptive_throttle import AdaptiveThrottle
7
7
  from .base import Concern
8
- from .bug_sla_checker import BugSLAChecker
9
8
  from .config import DispatcherConfig
10
- from .coral_manager import CoralManager
11
- from .coral_poker import CoralPoker
9
+ from .coral import CoralManager, CoralPoker
12
10
  from .file_watcher import FileWatcher
13
- from .health_checker import HealthChecker
11
+ from .health import HealthChecker, ResourceMonitor, SessionKeepAlive
14
12
  from .helpers import log, tmux_ok, warn
15
- from .idle_detector import IdleDetector
16
- from .idle_killer import IdleKiller
17
- from .idle_nudger import IdleNudger
18
- from .inbox_nudger import InboxNudger
19
- from .resource_monitor import ResourceMonitor
20
- from .session_keepalive import SessionKeepAlive
21
- from .time_announcer import TimeAnnouncer
13
+ from .idle import IdleDetector, IdleKiller, IdleNudger
14
+ from .notifications import BugSLAChecker, InboxNudger, TimeAnnouncer
22
15
 
23
16
  __all__ = [
24
17
  "AdaptiveThrottle",
@@ -1,12 +1,13 @@
1
- """CoralManager ensure dispatcher Claude session is running."""
1
+ """Coral: dispatcher session lifecycle management and heartbeat."""
2
2
 
3
+ import re
3
4
  import time
4
5
 
5
6
  from lib.common import is_suspended
6
7
 
7
8
  from .base import Concern
8
9
  from .config import DispatcherConfig
9
- from .helpers import is_claude_running, log, tmux, tmux_send
10
+ from .helpers import db, is_claude_running, log, pane_md5, tmux, tmux_ok, tmux_send
10
11
 
11
12
 
12
13
  class CoralManager(Concern):
@@ -49,8 +50,8 @@ class CoralManager(Concern):
49
50
  self._wait_until_ready()
50
51
 
51
52
  def _wait_until_ready(self) -> None:
52
- """Poll until the Claude process is running, or timeout after BOOT_WAIT."""
53
53
  import time as _time
54
+
54
55
  deadline = _time.monotonic() + self.BOOT_WAIT
55
56
  while _time.monotonic() < deadline:
56
57
  if is_claude_running(self.cfg.coral_sess):
@@ -59,3 +60,52 @@ class CoralManager(Concern):
59
60
  return
60
61
  _time.sleep(1)
61
62
  log(f"WARNING: Coral not ready after {self.BOOT_WAIT}s, will retry next tick")
63
+
64
+
65
+ class CoralPoker(Concern):
66
+ interval = 120
67
+
68
+ def __init__(self, cfg: DispatcherConfig) -> None:
69
+ super().__init__()
70
+ self.cfg = cfg
71
+ self.last_poke: int = int(time.time())
72
+
73
+ def poke(self, msg: str) -> bool:
74
+ if not tmux_ok("has-session", "-t", self.cfg.coral_sess) or not is_claude_running(self.cfg.coral_sess):
75
+ return False
76
+
77
+ content = tmux("capture-pane", "-t", self.cfg.coral_sess, "-p") or ""
78
+ prompts = [l for l in content.splitlines() if l.startswith("❯")]
79
+ if prompts and re.match(r"^❯ .{3,}", prompts[-1]):
80
+ log("Coral: skip (typing)")
81
+ return False
82
+
83
+ h1 = pane_md5(self.cfg.coral_sess)
84
+ time.sleep(1)
85
+ if h1 != pane_md5(self.cfg.coral_sess):
86
+ log("Coral: skip (busy)")
87
+ return False
88
+
89
+ log("Coral: poking")
90
+ tmux_send(self.cfg.coral_sess, msg)
91
+ self.last_poke = int(time.time())
92
+ return True
93
+
94
+ def tick(self, now: int) -> None:
95
+ unread = 0
96
+ if self.cfg.board_db.exists():
97
+ try:
98
+ unread = (
99
+ db(self.cfg).scalar(
100
+ "SELECT COUNT(*) FROM inbox WHERE session=? AND read=0",
101
+ (self.cfg.dispatcher_session,),
102
+ )
103
+ or 0
104
+ )
105
+ except Exception:
106
+ pass
107
+
108
+ if unread > 0:
109
+ self.poke(f"[Dispatcher] 你有 {unread} 条未读消息")
110
+ elif (now - self.last_poke) >= self.interval:
111
+ self.poke(f"[Dispatcher] heartbeat {time.strftime('%H:%M:%S')}")
@@ -8,7 +8,7 @@ from lib.common import is_suspended
8
8
  from .base import Concern
9
9
  from .config import DispatcherConfig
10
10
  from .helpers import log
11
- from .inbox_nudger import InboxNudger
11
+ from .notifications import InboxNudger
12
12
 
13
13
 
14
14
  class FileWatcher(Concern):
@@ -0,0 +1,136 @@
1
+ """Health monitoring: session keepalive, periodic health checks, resource monitoring."""
2
+
3
+ import time
4
+
5
+ from lib.common import date_to_epoch, is_suspended
6
+ from lib.resources import check_battery, check_cpu, check_memory
7
+
8
+ from .base import Concern
9
+ from .config import DispatcherConfig
10
+ from .coral import CoralManager, CoralPoker
11
+ from .helpers import (
12
+ board_send,
13
+ db,
14
+ get_dev_sessions,
15
+ is_claude_running,
16
+ log,
17
+ tmux_ok,
18
+ tmux_send,
19
+ )
20
+
21
+
22
+ class SessionKeepAlive(Concern):
23
+ interval = 5
24
+
25
+ def __init__(self, cfg: DispatcherConfig) -> None:
26
+ super().__init__()
27
+ self.cfg = cfg
28
+
29
+ def tick(self, now: int) -> None:
30
+ for name in get_dev_sessions(self.cfg):
31
+ if is_suspended(name, self.cfg.suspended_file):
32
+ continue
33
+ sess = f"{self.cfg.prefix}-{name}"
34
+ if tmux_ok("has-session", "-t", sess) and not is_claude_running(sess):
35
+ log(f"{name}: agent exited, NOT restarting (idle policy)")
36
+
37
+
38
+ class HealthChecker(Concern):
39
+ INITIAL = 600
40
+ MAX = 3600
41
+ IDLE_THRESHOLD = 1800
42
+
43
+ def __init__(self, cfg: DispatcherConfig, poker: CoralPoker, coral: CoralManager) -> None:
44
+ super().__init__()
45
+ self.cfg = cfg
46
+ self.interval = self.INITIAL
47
+ self.poker = poker
48
+ self.coral = coral
49
+ self.last_idle_alert: int = 0
50
+
51
+ def tick(self, now: int) -> None:
52
+ parts = []
53
+ for name in get_dev_sessions(self.cfg):
54
+ on = "on" if tmux_ok("has-session", "-t", f"{self.cfg.prefix}-{name}") else "off"
55
+ parts.append(f"{name}:{on}")
56
+ status = " ".join(parts)
57
+
58
+ log(f"Health check (interval:{self.interval}s): {status}")
59
+ self.poker.poke(f"[Dispatcher] 健康巡检 {time.strftime('%H:%M:%S')}: {status}")
60
+ self.interval = min(self.interval * 2, self.MAX)
61
+ self._check_team_idle(now)
62
+
63
+ def _check_team_idle(self, now: int) -> None:
64
+ if not self.cfg.board_db.exists():
65
+ return
66
+ idle_list: list[str] = []
67
+ total = 0
68
+ d = db(self.cfg)
69
+
70
+ for name in get_dev_sessions(self.cfg):
71
+ if is_suspended(name, self.cfg.suspended_file):
72
+ continue
73
+ sess = f"{self.cfg.prefix}-{name}"
74
+ if not tmux_ok("has-session", "-t", sess) or not is_claude_running(sess):
75
+ continue
76
+ if self.coral.in_grace_period(name, now):
77
+ continue
78
+
79
+ total += 1
80
+ try:
81
+ updated = d.scalar("SELECT updated_at FROM sessions WHERE name=?", (name,)) or ""
82
+ except Exception:
83
+ continue
84
+ if updated:
85
+ age = now - date_to_epoch(updated)
86
+ if age > self.IDLE_THRESHOLD:
87
+ idle_list.append(f"{name}({age}s)")
88
+
89
+ if idle_list and len(idle_list) == total and (now - self.last_idle_alert) > 3600:
90
+ log(f"All sessions idle: {' '.join(idle_list)}")
91
+ board_send(
92
+ self.cfg,
93
+ "lead",
94
+ f"[Dispatcher] 全员空闲超过 {self.IDLE_THRESHOLD // 60} 分钟:{' '.join(idle_list)}。可能需要分配工作。",
95
+ )
96
+ self.last_idle_alert = now
97
+
98
+
99
+ class ResourceMonitor(Concern):
100
+ interval = 60
101
+
102
+ def __init__(self, cfg: DispatcherConfig) -> None:
103
+ super().__init__()
104
+ self.cfg = cfg
105
+ self.last_state = ""
106
+
107
+ def tick(self, now: int) -> None:
108
+ batt = check_battery()
109
+ mem = check_memory()
110
+ cpu = check_cpu()
111
+
112
+ state = f"{batt.status}|{batt.pct}|{mem.status}|{mem.pressure}|{cpu.status}|{cpu.usage}"
113
+ if state == self.last_state:
114
+ return
115
+ self.last_state = state
116
+
117
+ if batt.status == "CRITICAL":
118
+ log(f"RESOURCE: Battery CRITICAL ({batt.pct}%)")
119
+ board_send(self.cfg, "All", f"[Resource] 电池严重不足 ({batt.pct}%),暂停非关键 session。")
120
+ for name in get_dev_sessions(self.cfg):
121
+ sess = f"{self.cfg.prefix}-{name}"
122
+ if is_claude_running(sess):
123
+ tmux_send(sess, "[系统] 电池严重不足,请立即保存状态。")
124
+ elif batt.status == "LOW":
125
+ log(f"RESOURCE: Battery LOW ({batt.pct}%)")
126
+ board_send(self.cfg, "All", f"[Resource] 电池低 ({batt.pct}%),建议减少活跃 session 到 2-3 个。")
127
+
128
+ if mem.status == "CRITICAL":
129
+ log("RESOURCE: Memory pressure CRITICAL")
130
+ board_send(self.cfg, "All", "[Resource] 内存压力严重!建议重启最大的 session 释放内存。")
131
+ elif mem.status == "WARNING":
132
+ log("RESOURCE: Memory pressure WARNING")
133
+ board_send(self.cfg, "All", "[Resource] 内存压力升高,关注中。")
134
+
135
+ if cpu.status == "SATURATED":
136
+ log(f"RESOURCE: CPU saturated ({cpu.usage}%)")
@@ -70,11 +70,7 @@ def get_dev_sessions(cfg: DispatcherConfig) -> list[str]:
70
70
  return []
71
71
  pfx = f"{cfg.prefix}-"
72
72
  protected = {"dispatcher", "lead"}
73
- return [
74
- line[len(pfx) :]
75
- for line in raw.splitlines()
76
- if line.startswith(pfx) and line[len(pfx) :] not in protected
77
- ]
73
+ return [line[len(pfx) :] for line in raw.splitlines() if line.startswith(pfx) and line[len(pfx) :] not in protected]
78
74
 
79
75
 
80
76
  def pane_md5(sess: str) -> str:
@@ -0,0 +1,130 @@
1
+ """Idle detection, nudging, and killing for dispatcher sessions."""
2
+
3
+ import re
4
+
5
+ from lib.common import is_suspended
6
+
7
+ from .base import Concern
8
+ from .config import DispatcherConfig
9
+ from .coral import CoralManager
10
+ from .helpers import (
11
+ board_send,
12
+ get_dev_sessions,
13
+ has_tool_process,
14
+ is_claude_running,
15
+ log,
16
+ pane_md5,
17
+ tmux,
18
+ tmux_ok,
19
+ tmux_send,
20
+ )
21
+
22
+
23
+ class IdleDetector(Concern):
24
+ interval = 5
25
+
26
+ def __init__(self, cfg: DispatcherConfig) -> None:
27
+ super().__init__()
28
+ self.cfg = cfg
29
+ self.cache: dict[str, str] = {} # sess -> "idle" | "busy"
30
+ self._prev_snap: dict[str, str] = {} # sess -> md5 from previous tick
31
+
32
+ def is_idle(self, sess: str) -> bool:
33
+ return self.cache.get(sess) == "idle"
34
+
35
+ def tick(self, now: int) -> None:
36
+ self.cache.clear()
37
+ raw = tmux("list-sessions", "-F", "#{session_name}")
38
+ if not raw:
39
+ self._prev_snap.clear()
40
+ return
41
+
42
+ all_sessions = [s for s in raw.splitlines() if s.startswith(f"{self.cfg.prefix}-")]
43
+ need_snapshot: list[str] = []
44
+
45
+ for sess in all_sessions:
46
+ pane = tmux("capture-pane", "-t", sess, "-p") or ""
47
+ prompts = [l for l in pane.splitlines() if l.startswith("❯")]
48
+ if prompts and re.match(r"^❯ .{3,}", prompts[-1]):
49
+ self.cache[sess] = "busy"
50
+ continue
51
+ if has_tool_process(sess):
52
+ self.cache[sess] = "busy"
53
+ continue
54
+ need_snapshot.append(sess)
55
+
56
+ current_snap: dict[str, str] = {}
57
+ for sess in need_snapshot:
58
+ md5 = pane_md5(sess)
59
+ current_snap[sess] = md5
60
+ if sess in self._prev_snap and self._prev_snap[sess] == md5:
61
+ self.cache[sess] = "idle"
62
+ else:
63
+ self.cache[sess] = "busy"
64
+
65
+ self._prev_snap = {s: pane_md5(s) for s in all_sessions if s not in self.cache or self.cache[s] != "busy"}
66
+ self._prev_snap.update(current_snap)
67
+
68
+
69
+ class IdleKiller(Concern):
70
+ interval = 5
71
+ THRESHOLD = 1800
72
+
73
+ def __init__(self, cfg: DispatcherConfig, idle: IdleDetector, coral: CoralManager) -> None:
74
+ super().__init__()
75
+ self.cfg = cfg
76
+ self.idle = idle
77
+ self.coral = coral
78
+ self.idle_since: dict[str, int] = {}
79
+
80
+ def tick(self, now: int) -> None:
81
+ for name in get_dev_sessions(self.cfg):
82
+ sess = f"{self.cfg.prefix}-{name}"
83
+ if not tmux_ok("has-session", "-t", sess) or not is_claude_running(sess):
84
+ self.idle_since.pop(name, None)
85
+ continue
86
+
87
+ if name not in self.coral.boot_times:
88
+ self.coral.record_boot(name)
89
+ if self.coral.in_grace_period(name, now):
90
+ continue
91
+
92
+ if self.idle.is_idle(sess):
93
+ since = self.idle_since.setdefault(name, now)
94
+ if (now - since) >= self.THRESHOLD:
95
+ log(f"{name}: idle {now - since}s (>30min), killing session")
96
+ tmux("kill-session", "-t", sess)
97
+ self.idle_since.pop(name, None)
98
+ board_send(self.cfg, "All", f"[Dispatcher] {name} 闲置超过 30 分钟,已终止。")
99
+ else:
100
+ self.idle_since.pop(name, None)
101
+
102
+
103
+ class IdleNudger(Concern):
104
+ interval = 5
105
+ COOLDOWN = 300
106
+
107
+ def __init__(self, cfg: DispatcherConfig, idle: IdleDetector) -> None:
108
+ super().__init__()
109
+ self.cfg = cfg
110
+ self.idle = idle
111
+ self.last_nudge: dict[str, int] = {}
112
+
113
+ def tick(self, now: int) -> None:
114
+ for name in get_dev_sessions(self.cfg):
115
+ if is_suspended(name, self.cfg.suspended_file):
116
+ continue
117
+ sess = f"{self.cfg.prefix}-{name}"
118
+ if not tmux_ok("has-session", "-t", sess) or not is_claude_running(sess):
119
+ continue
120
+ if not self.idle.is_idle(sess):
121
+ continue
122
+ if (now - self.last_nudge.get(name, 0)) < self.COOLDOWN:
123
+ continue
124
+
125
+ log(f"{name}: idle, nudging autonomous loop")
126
+ tmux_send(
127
+ sess,
128
+ f"继续工作。检查你的 OKR ({self.cfg.okr_dir}/{name}.md),推进你的活跃 KR。自己决定优先级。",
129
+ )
130
+ self.last_nudge[name] = now
@@ -0,0 +1,90 @@
1
+ """Notifications: inbox nudging, time announcements, bug SLA checks."""
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_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
+ self.last_hour = -1
47
+
48
+ def tick(self, now: int) -> None:
49
+ from datetime import datetime as dt
50
+
51
+ d = dt.now()
52
+ if d.minute != 0 or d.hour == self.last_hour:
53
+ return
54
+ self.last_hour = d.hour
55
+ ts = d.strftime("%Y-%m-%d %H:%M")
56
+
57
+ if d.hour == 9:
58
+ board_send(
59
+ self.cfg,
60
+ "All",
61
+ f"[Clock] {ts} ({d.strftime('%A')}) — 新一天。检查 KR 列表,确认优先级。",
62
+ )
63
+ log("Daily announcement sent")
64
+ else:
65
+ board_send(self.cfg, "All", f"[Clock] 现在是 {ts}。")
66
+ log(f"Hourly announcement: {d.hour}:00")
67
+
68
+
69
+ class BugSLAChecker(Concern):
70
+ interval = 600
71
+
72
+ def __init__(self, cfg: DispatcherConfig, poker: CoralPoker) -> None:
73
+ super().__init__()
74
+ self.cfg = cfg
75
+ self.poker = poker
76
+
77
+ def tick(self, now: int) -> None:
78
+ try:
79
+ r = subprocess.run(
80
+ [self.cfg.board_sh, "bug", "overdue"],
81
+ capture_output=True,
82
+ text=True,
83
+ timeout=10,
84
+ )
85
+ overdue = r.stdout.strip()
86
+ except Exception:
87
+ return
88
+ if overdue and "No overdue" not in overdue:
89
+ log(f"Bug SLA alert: {overdue}")
90
+ self.poker.poke(f"[Dispatcher] Bug SLA 超时: {overdue}")
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