claude-nb 0.3.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.
Files changed (65) hide show
  1. package/LICENSE +38 -0
  2. package/Makefile +60 -0
  3. package/README.md +63 -0
  4. package/VERSION +1 -0
  5. package/bin/_pip_entry.py +25 -0
  6. package/bin/board +287 -0
  7. package/bin/cnb +150 -0
  8. package/bin/cnb.js +33 -0
  9. package/bin/dispatcher +151 -0
  10. package/bin/dispatcher-watchdog +57 -0
  11. package/bin/doctor +328 -0
  12. package/bin/init +316 -0
  13. package/bin/registry +347 -0
  14. package/bin/swarm +896 -0
  15. package/lib/__init__.py +1 -0
  16. package/lib/board_admin.py +128 -0
  17. package/lib/board_bbs.py +99 -0
  18. package/lib/board_bug.py +161 -0
  19. package/lib/board_db.py +262 -0
  20. package/lib/board_lock.py +113 -0
  21. package/lib/board_mailbox.py +145 -0
  22. package/lib/board_maintenance.py +237 -0
  23. package/lib/board_msg.py +230 -0
  24. package/lib/board_task.py +200 -0
  25. package/lib/board_view.py +366 -0
  26. package/lib/board_vote.py +164 -0
  27. package/lib/build_lock.py +221 -0
  28. package/lib/cli.py +34 -0
  29. package/lib/common.py +285 -0
  30. package/lib/concerns/__init__.py +42 -0
  31. package/lib/concerns/adaptive_throttle.py +26 -0
  32. package/lib/concerns/base.py +25 -0
  33. package/lib/concerns/bug_sla_checker.py +32 -0
  34. package/lib/concerns/config.py +22 -0
  35. package/lib/concerns/coral_manager.py +61 -0
  36. package/lib/concerns/coral_poker.py +57 -0
  37. package/lib/concerns/file_watcher.py +127 -0
  38. package/lib/concerns/health_checker.py +72 -0
  39. package/lib/concerns/helpers.py +152 -0
  40. package/lib/concerns/idle_detector.py +56 -0
  41. package/lib/concerns/idle_killer.py +41 -0
  42. package/lib/concerns/idle_nudger.py +38 -0
  43. package/lib/concerns/inbox_nudger.py +34 -0
  44. package/lib/concerns/resource_monitor.py +47 -0
  45. package/lib/concerns/session_keepalive.py +23 -0
  46. package/lib/concerns/time_announcer.py +34 -0
  47. package/lib/crypto.py +92 -0
  48. package/lib/health.py +187 -0
  49. package/lib/inject.py +164 -0
  50. package/lib/migrate.py +109 -0
  51. package/lib/monitor.py +373 -0
  52. package/lib/panel.py +137 -0
  53. package/lib/resources.py +341 -0
  54. package/migrations/001_foreign_keys.sql +77 -0
  55. package/migrations/002_session_persona.sql +1 -0
  56. package/migrations/003_mailbox.sql +9 -0
  57. package/package.json +28 -0
  58. package/pyproject.toml +71 -0
  59. package/registry/0001-meridian.json +12 -0
  60. package/registry/0002-forge.json +12 -0
  61. package/registry/0003-lead.json +12 -0
  62. package/registry/0004-ms-encrypted-mailbox-live.json +12 -0
  63. package/registry/GENESIS.json +9 -0
  64. package/registry/pubkeys.json +5 -0
  65. package/schema.sql +138 -0
@@ -0,0 +1,61 @@
1
+ """CoralManager — ensure dispatcher Claude session is running."""
2
+
3
+ import time
4
+
5
+ from lib.common import is_suspended
6
+
7
+ from .base import Concern
8
+ from .config import DispatcherConfig
9
+ from .helpers import is_claude_running, log, tmux, tmux_send
10
+
11
+
12
+ class CoralManager(Concern):
13
+ interval = 5
14
+ BOOT_WAIT = 8
15
+ BOOT_GRACE = 120
16
+
17
+ def __init__(self, cfg: DispatcherConfig) -> None:
18
+ super().__init__()
19
+ self.cfg = cfg
20
+ self.boot_times: dict[str, int] = {}
21
+
22
+ def record_boot(self, name: str) -> None:
23
+ self.boot_times[name] = int(time.time())
24
+
25
+ def in_grace_period(self, name: str, now: int) -> bool:
26
+ bt = self.boot_times.get(name)
27
+ return bt is not None and (now - bt) < self.BOOT_GRACE
28
+
29
+ def tick(self, now: int) -> None:
30
+ dev_alive = any(is_claude_running(f"{self.cfg.prefix}-{s}") for s in self.cfg.dev_sessions)
31
+ if dev_alive and not is_suspended("dispatcher", self.cfg.suspended_file):
32
+ self._ensure()
33
+
34
+ def _ensure(self) -> None:
35
+ if is_claude_running(self.cfg.coral_sess):
36
+ return
37
+ log("Starting Coral...")
38
+ tmux("kill-session", "-t", self.cfg.coral_sess)
39
+ tmux("new-session", "-d", "-s", self.cfg.coral_sess, "-x", "200", "-y", "50")
40
+ tmux_send(self.cfg.coral_sess, f"cd '{self.cfg.project_root}'")
41
+ time.sleep(0.5)
42
+ tmux_send(
43
+ self.cfg.coral_sess,
44
+ "claude --name dispatcher --append-system-prompt "
45
+ "'你是 Coral。启动后运行 cat dispatcher-role.md,然后等指令。回复不超过3行。'",
46
+ )
47
+ self.record_boot("dispatcher")
48
+ log(f"Coral boot sent, waiting for Claude process (max {self.BOOT_WAIT}s)...")
49
+ self._wait_until_ready()
50
+
51
+ def _wait_until_ready(self) -> None:
52
+ """Poll until the Claude process is running, or timeout after BOOT_WAIT."""
53
+ import time as _time
54
+ deadline = _time.monotonic() + self.BOOT_WAIT
55
+ while _time.monotonic() < deadline:
56
+ if is_claude_running(self.cfg.coral_sess):
57
+ elapsed = self.BOOT_WAIT - (deadline - _time.monotonic())
58
+ log(f"Coral ready after {elapsed:.1f}s")
59
+ return
60
+ _time.sleep(1)
61
+ log(f"WARNING: Coral not ready after {self.BOOT_WAIT}s, will retry next tick")
@@ -0,0 +1,57 @@
1
+ """CoralPoker — periodic heartbeat to dispatcher session."""
2
+
3
+ import re
4
+ import time
5
+
6
+ from .base import Concern
7
+ from .config import DispatcherConfig
8
+ from .helpers import db, is_claude_running, log, pane_md5, tmux, tmux_ok, tmux_send
9
+
10
+
11
+ class CoralPoker(Concern):
12
+ interval = 120
13
+
14
+ def __init__(self, cfg: DispatcherConfig) -> None:
15
+ super().__init__()
16
+ self.cfg = cfg
17
+ self.last_poke: int = int(time.time())
18
+
19
+ def poke(self, msg: str) -> bool:
20
+ if not tmux_ok("has-session", "-t", self.cfg.coral_sess) or not is_claude_running(self.cfg.coral_sess):
21
+ return False
22
+
23
+ content = tmux("capture-pane", "-t", self.cfg.coral_sess, "-p") or ""
24
+ prompts = [l for l in content.splitlines() if l.startswith("❯")]
25
+ if prompts and re.match(r"^❯ .{3,}", prompts[-1]):
26
+ log("Coral: skip (typing)")
27
+ return False
28
+
29
+ h1 = pane_md5(self.cfg.coral_sess)
30
+ time.sleep(1)
31
+ if h1 != pane_md5(self.cfg.coral_sess):
32
+ log("Coral: skip (busy)")
33
+ return False
34
+
35
+ log("Coral: poking")
36
+ tmux_send(self.cfg.coral_sess, msg)
37
+ self.last_poke = int(time.time())
38
+ return True
39
+
40
+ def tick(self, now: int) -> None:
41
+ unread = 0
42
+ if self.cfg.board_db.exists():
43
+ try:
44
+ unread = (
45
+ db(self.cfg).scalar(
46
+ "SELECT COUNT(*) FROM inbox WHERE session=? AND read=0",
47
+ (self.cfg.dispatcher_session,),
48
+ )
49
+ or 0
50
+ )
51
+ except Exception:
52
+ pass
53
+
54
+ if unread > 0:
55
+ self.poke(f"[Dispatcher] 你有 {unread} 条未读消息")
56
+ elif (now - self.last_poke) >= self.interval:
57
+ self.poke(f"[Dispatcher] heartbeat {time.strftime('%H:%M:%S')}")
@@ -0,0 +1,127 @@
1
+ """FileWatcher — kqueue-based instant inbox detection (background thread)."""
2
+
3
+ import os
4
+ import threading
5
+
6
+ from lib.common import is_suspended
7
+
8
+ from .base import Concern
9
+ from .config import DispatcherConfig
10
+ from .helpers import log
11
+ from .inbox_nudger import InboxNudger
12
+
13
+
14
+ class FileWatcher(Concern):
15
+ interval = 1
16
+
17
+ def __init__(self, cfg: DispatcherConfig, inbox: InboxNudger) -> None:
18
+ super().__init__()
19
+ self.cfg = cfg
20
+ self.inbox = inbox
21
+ self._queue: list[str] = []
22
+ self._lock = threading.Lock()
23
+ self._stop = threading.Event()
24
+ self._thread: threading.Thread | None = None
25
+
26
+ def start(self) -> bool:
27
+ try:
28
+ import select as sel
29
+
30
+ if not hasattr(sel, "kqueue"):
31
+ log("kqueue unavailable, using polling only")
32
+ return False
33
+ except ImportError:
34
+ return False
35
+ self._thread = threading.Thread(target=self._loop, daemon=True, name="file-watcher")
36
+ self._thread.start()
37
+ log("File watcher thread started")
38
+ return True
39
+
40
+ def _loop(self) -> None:
41
+ import select as sel
42
+
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
+ )
57
+ file_fds: dict[str, int] = {}
58
+
59
+ def refresh() -> None:
60
+ for f in os.listdir(watch_dir):
61
+ if not f.endswith(".md"):
62
+ continue
63
+ path = os.path.join(watch_dir, f)
64
+ if path in file_fds:
65
+ continue
66
+ 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
80
+ except OSError:
81
+ 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()
115
+
116
+ def stop(self) -> None:
117
+ self._stop.set()
118
+ if self._thread and self._thread.is_alive():
119
+ self._thread.join(timeout=5)
120
+
121
+ def tick(self, now: int) -> None:
122
+ with self._lock:
123
+ names = list(self._queue)
124
+ self._queue.clear()
125
+ for name in names:
126
+ if not is_suspended(name, self.cfg.suspended_file):
127
+ self.inbox.nudge_if_unread(name)
@@ -0,0 +1,72 @@
1
+ """HealthChecker — periodic status report + team idle detection."""
2
+
3
+ import time
4
+
5
+ from lib.common import date_to_epoch, is_suspended
6
+
7
+ from .base import Concern
8
+ from .config import DispatcherConfig
9
+ from .coral_manager import CoralManager
10
+ from .coral_poker import CoralPoker
11
+ from .helpers import board_send, db, get_dev_sessions, is_claude_running, log, tmux_ok
12
+
13
+
14
+ class HealthChecker(Concern):
15
+ INITIAL = 600
16
+ MAX = 3600
17
+ IDLE_THRESHOLD = 1800
18
+
19
+ def __init__(self, cfg: DispatcherConfig, poker: CoralPoker, coral: CoralManager) -> None:
20
+ super().__init__()
21
+ self.cfg = cfg
22
+ self.interval = self.INITIAL
23
+ self.poker = poker
24
+ self.coral = coral
25
+ self.last_idle_alert: int = 0
26
+
27
+ def tick(self, now: int) -> None:
28
+ parts = []
29
+ for name in get_dev_sessions(self.cfg):
30
+ on = "on" if tmux_ok("has-session", "-t", f"{self.cfg.prefix}-{name}") else "off"
31
+ parts.append(f"{name}:{on}")
32
+ status = " ".join(parts)
33
+
34
+ log(f"Health check (interval:{self.interval}s): {status}")
35
+ self.poker.poke(f"[Dispatcher] 健康巡检 {time.strftime('%H:%M:%S')}: {status}")
36
+ self.interval = min(self.interval * 2, self.MAX)
37
+ self._check_team_idle(now)
38
+
39
+ def _check_team_idle(self, now: int) -> None:
40
+ if not self.cfg.board_db.exists():
41
+ return
42
+ idle_list: list[str] = []
43
+ total = 0
44
+ d = db(self.cfg)
45
+
46
+ for name in get_dev_sessions(self.cfg):
47
+ if is_suspended(name, self.cfg.suspended_file):
48
+ continue
49
+ sess = f"{self.cfg.prefix}-{name}"
50
+ if not tmux_ok("has-session", "-t", sess) or not is_claude_running(sess):
51
+ continue
52
+ if self.coral.in_grace_period(name, now):
53
+ continue
54
+
55
+ total += 1
56
+ try:
57
+ updated = d.scalar("SELECT updated_at FROM sessions WHERE name=?", (name,)) or ""
58
+ except Exception:
59
+ continue
60
+ if updated:
61
+ age = now - date_to_epoch(updated)
62
+ if age > self.IDLE_THRESHOLD:
63
+ idle_list.append(f"{name}({age}s)")
64
+
65
+ if idle_list and len(idle_list) == total and (now - self.last_idle_alert) > 3600:
66
+ log(f"All sessions idle: {' '.join(idle_list)}")
67
+ board_send(
68
+ self.cfg,
69
+ "lead",
70
+ f"[Dispatcher] 全员空闲超过 {self.IDLE_THRESHOLD // 60} 分钟:{' '.join(idle_list)}。可能需要分配工作。",
71
+ )
72
+ self.last_idle_alert = now
@@ -0,0 +1,152 @@
1
+ """Shared helper functions for dispatcher concerns (tmux, board, etc.)."""
2
+
3
+ import hashlib
4
+ import subprocess
5
+ import sys
6
+ import time
7
+ from pathlib import Path
8
+
9
+ from lib.board_db import BoardDB
10
+
11
+ from .config import DispatcherConfig
12
+
13
+ # ── logging ──
14
+
15
+
16
+ def log(msg: str) -> None:
17
+ print(f"[dispatcher] {time.strftime('%H:%M:%S')} {msg}", flush=True)
18
+
19
+
20
+ def warn(msg: str) -> None:
21
+ print(f"[dispatcher] {time.strftime('%H:%M:%S')} WARN {msg}", flush=True, file=sys.stderr)
22
+
23
+
24
+ # ── tmux ──
25
+
26
+ TMUX_TIMEOUT = 8 # seconds
27
+
28
+
29
+ def tmux(*args: str) -> str | None:
30
+ try:
31
+ r = subprocess.run(["tmux", *args], capture_output=True, text=True, timeout=TMUX_TIMEOUT)
32
+ return r.stdout.strip() if r.returncode == 0 else None
33
+ except (subprocess.TimeoutExpired, OSError) as e:
34
+ warn(f"tmux {' '.join(args)}: {e}")
35
+ return None
36
+
37
+
38
+ def tmux_ok(*args: str) -> bool:
39
+ try:
40
+ return subprocess.run(["tmux", *args], capture_output=True, timeout=TMUX_TIMEOUT).returncode == 0
41
+ except (subprocess.TimeoutExpired, OSError) as e:
42
+ warn(f"tmux_ok {' '.join(args)}: {e}")
43
+ return False
44
+
45
+
46
+ def tmux_send(sess: str, text: str) -> bool:
47
+ """Send keys to a tmux session. Returns True on success."""
48
+ try:
49
+ subprocess.run(["tmux", "send-keys", "-t", sess, "-l", text], timeout=TMUX_TIMEOUT, check=True)
50
+ subprocess.run(["tmux", "send-keys", "-t", sess, "Enter"], timeout=TMUX_TIMEOUT, check=True)
51
+ return True
52
+ except (subprocess.CalledProcessError, subprocess.TimeoutExpired, OSError) as e:
53
+ warn(f"tmux_send {sess}: {e}")
54
+ return False
55
+
56
+
57
+ def is_claude_running(sess: str) -> bool:
58
+ if not tmux_ok("has-session", "-t", sess):
59
+ return False
60
+ cmd = tmux("list-panes", "-t", sess, "-F", "#{pane_current_command}")
61
+ if not cmd:
62
+ return False
63
+ first = cmd.splitlines()[0] if cmd else ""
64
+ return first not in ("zsh", "bash", "sh", "-zsh", "-bash", "")
65
+
66
+
67
+ def get_dev_sessions(cfg: DispatcherConfig) -> list[str]:
68
+ raw = tmux("list-sessions", "-F", "#{session_name}")
69
+ if not raw:
70
+ return []
71
+ pfx = f"{cfg.prefix}-"
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
+ ]
78
+
79
+
80
+ def pane_md5(sess: str) -> str:
81
+ content = tmux("capture-pane", "-t", sess, "-p") or ""
82
+ return hashlib.md5(content.encode()).hexdigest()
83
+
84
+
85
+ # ── board ──
86
+
87
+
88
+ def board_send(cfg: DispatcherConfig, target: str, msg: str) -> None:
89
+ try:
90
+ subprocess.run(
91
+ [cfg.board_sh, "--as", "dispatcher", "send", target, msg],
92
+ capture_output=True,
93
+ timeout=10,
94
+ )
95
+ except (subprocess.TimeoutExpired, OSError) as e:
96
+ warn(f"board_send {target}: {e}")
97
+
98
+
99
+ _db_cache: dict[Path, BoardDB] = {}
100
+
101
+
102
+ def db(cfg: DispatcherConfig) -> BoardDB:
103
+ if cfg.board_db not in _db_cache:
104
+ _db_cache[cfg.board_db] = BoardDB(cfg.board_db)
105
+ return _db_cache[cfg.board_db]
106
+
107
+
108
+ # ── process inspection ──
109
+
110
+
111
+ def has_tool_process(sess: str) -> bool:
112
+ """Check if Claude has spawned transient child processes (not caffeinate/uv)."""
113
+ pane_pid = tmux("display-message", "-t", sess, "-p", "#{pane_pid}")
114
+ if not pane_pid:
115
+ return False
116
+ try:
117
+ r = subprocess.run(
118
+ ["pgrep", "-P", pane_pid],
119
+ capture_output=True,
120
+ text=True,
121
+ timeout=3,
122
+ )
123
+ claude_pid = (r.stdout.strip().splitlines() or [""])[0]
124
+ if not claude_pid:
125
+ return False
126
+ r2 = subprocess.run(
127
+ ["pgrep", "-P", claude_pid],
128
+ capture_output=True,
129
+ text=True,
130
+ timeout=3,
131
+ )
132
+ for cpid in r2.stdout.strip().splitlines():
133
+ if not cpid:
134
+ continue
135
+ r3 = subprocess.run(
136
+ ["ps", "-p", cpid, "-o", "comm="],
137
+ capture_output=True,
138
+ text=True,
139
+ timeout=3,
140
+ )
141
+ name = r3.stdout.strip().rsplit("/", 1)[-1]
142
+ if name and name not in ("caffeinate", "uv", ""):
143
+ return True
144
+ except (subprocess.TimeoutExpired, OSError) as e:
145
+ warn(f"has_tool_process {sess}: {e}")
146
+ return False
147
+
148
+
149
+ def is_pane_typing(sess: str) -> bool:
150
+ """Check if a pane has an active prompt with typing."""
151
+ content = tmux("capture-pane", "-t", sess, "-p") or ""
152
+ return any(l.startswith("❯ ") and len(l) > 3 for l in content.splitlines())
@@ -0,0 +1,56 @@
1
+ """IdleDetector — batch screen snapshot comparison (non-blocking).
2
+
3
+ Compares snapshots across consecutive ticks instead of sleeping mid-tick.
4
+ """
5
+
6
+ import re
7
+
8
+ from .base import Concern
9
+ from .config import DispatcherConfig
10
+ from .helpers import has_tool_process, pane_md5, tmux
11
+
12
+
13
+ class IdleDetector(Concern):
14
+ interval = 5
15
+
16
+ def __init__(self, cfg: DispatcherConfig) -> None:
17
+ super().__init__()
18
+ self.cfg = cfg
19
+ self.cache: dict[str, str] = {} # sess -> "idle" | "busy"
20
+ self._prev_snap: dict[str, str] = {} # sess -> md5 from previous tick
21
+
22
+ def is_idle(self, sess: str) -> bool:
23
+ return self.cache.get(sess) == "idle"
24
+
25
+ def tick(self, now: int) -> None:
26
+ self.cache.clear()
27
+ raw = tmux("list-sessions", "-F", "#{session_name}")
28
+ if not raw:
29
+ self._prev_snap.clear()
30
+ return
31
+
32
+ all_sessions = [s for s in raw.splitlines() if s.startswith(f"{self.cfg.prefix}-")]
33
+ need_snapshot: list[str] = []
34
+
35
+ for sess in all_sessions:
36
+ pane = tmux("capture-pane", "-t", sess, "-p") or ""
37
+ prompts = [l for l in pane.splitlines() if l.startswith("❯")]
38
+ if prompts and re.match(r"^❯ .{3,}", prompts[-1]):
39
+ self.cache[sess] = "busy"
40
+ continue
41
+ if has_tool_process(sess):
42
+ self.cache[sess] = "busy"
43
+ continue
44
+ need_snapshot.append(sess)
45
+
46
+ current_snap: dict[str, str] = {}
47
+ for sess in need_snapshot:
48
+ md5 = pane_md5(sess)
49
+ current_snap[sess] = md5
50
+ if sess in self._prev_snap and self._prev_snap[sess] == md5:
51
+ self.cache[sess] = "idle"
52
+ else:
53
+ self.cache[sess] = "busy"
54
+
55
+ self._prev_snap = {s: pane_md5(s) for s in all_sessions if s not in self.cache or self.cache[s] != "busy"}
56
+ self._prev_snap.update(current_snap)
@@ -0,0 +1,41 @@
1
+ """IdleKiller — kill sessions idle >30min."""
2
+
3
+ from .base import Concern
4
+ from .config import DispatcherConfig
5
+ from .coral_manager import CoralManager
6
+ from .helpers import board_send, get_dev_sessions, is_claude_running, log, tmux, tmux_ok
7
+ from .idle_detector import IdleDetector
8
+
9
+
10
+ class IdleKiller(Concern):
11
+ interval = 5
12
+ THRESHOLD = 1800
13
+
14
+ def __init__(self, cfg: DispatcherConfig, idle: IdleDetector, coral: CoralManager) -> None:
15
+ super().__init__()
16
+ self.cfg = cfg
17
+ self.idle = idle
18
+ self.coral = coral
19
+ self.idle_since: dict[str, int] = {}
20
+
21
+ def tick(self, now: int) -> None:
22
+ for name in get_dev_sessions(self.cfg):
23
+ sess = f"{self.cfg.prefix}-{name}"
24
+ if not tmux_ok("has-session", "-t", sess) or not is_claude_running(sess):
25
+ self.idle_since.pop(name, None)
26
+ continue
27
+
28
+ if name not in self.coral.boot_times:
29
+ self.coral.record_boot(name)
30
+ if self.coral.in_grace_period(name, now):
31
+ continue
32
+
33
+ if self.idle.is_idle(sess):
34
+ since = self.idle_since.setdefault(name, now)
35
+ if (now - since) >= self.THRESHOLD:
36
+ log(f"{name}: idle {now - since}s (>30min), killing session")
37
+ tmux("kill-session", "-t", sess)
38
+ self.idle_since.pop(name, None)
39
+ board_send(self.cfg, "All", f"[Dispatcher] {name} 闲置超过 30 分钟,已终止。")
40
+ else:
41
+ self.idle_since.pop(name, None)
@@ -0,0 +1,38 @@
1
+ """IdleNudger — nudge idle sessions to continue working."""
2
+
3
+ from lib.common import is_suspended
4
+
5
+ from .base import Concern
6
+ from .config import DispatcherConfig
7
+ from .helpers import get_dev_sessions, is_claude_running, log, tmux_ok, tmux_send
8
+ from .idle_detector import IdleDetector
9
+
10
+
11
+ class IdleNudger(Concern):
12
+ interval = 5
13
+ COOLDOWN = 300
14
+
15
+ def __init__(self, cfg: DispatcherConfig, idle: IdleDetector) -> None:
16
+ super().__init__()
17
+ self.cfg = cfg
18
+ self.idle = idle
19
+ self.last_nudge: dict[str, int] = {}
20
+
21
+ def tick(self, now: int) -> None:
22
+ for name in get_dev_sessions(self.cfg):
23
+ if is_suspended(name, self.cfg.suspended_file):
24
+ continue
25
+ sess = f"{self.cfg.prefix}-{name}"
26
+ if not tmux_ok("has-session", "-t", sess) or not is_claude_running(sess):
27
+ continue
28
+ if not self.idle.is_idle(sess):
29
+ continue
30
+ if (now - self.last_nudge.get(name, 0)) < self.COOLDOWN:
31
+ continue
32
+
33
+ log(f"{name}: idle, nudging autonomous loop")
34
+ tmux_send(
35
+ sess,
36
+ f"继续工作。检查你的 OKR ({self.cfg.okr_dir}/{name}.md),推进你的活跃 KR。自己决定优先级。",
37
+ )
38
+ self.last_nudge[name] = now
@@ -0,0 +1,34 @@
1
+ """InboxNudger — detect unread inboxes, nudge sessions."""
2
+
3
+ from .base import Concern
4
+ from .config import DispatcherConfig
5
+ from .helpers import db, get_dev_sessions, is_claude_running, log, tmux_ok, tmux_send
6
+
7
+
8
+ class InboxNudger(Concern):
9
+ interval = 5
10
+
11
+ def __init__(self, cfg: DispatcherConfig) -> None:
12
+ super().__init__()
13
+ self.cfg = cfg
14
+
15
+ def nudge_if_unread(self, name: str) -> None:
16
+ if not self.cfg.board_db.exists():
17
+ return
18
+ try:
19
+ unread = db(self.cfg).scalar("SELECT COUNT(*) FROM inbox WHERE session=? AND read=0", (name,)) or 0
20
+ except Exception:
21
+ return
22
+ if unread <= 0:
23
+ return
24
+
25
+ sess = f"{self.cfg.prefix}-{name}"
26
+ if not tmux_ok("has-session", "-t", sess) or not is_claude_running(sess):
27
+ return
28
+
29
+ log(f"INBOX: {name} has {unread} unread -> nudging")
30
+ tmux_send(sess, f"./board --as {name} inbox")
31
+
32
+ def tick(self, now: int) -> None:
33
+ for name in get_dev_sessions(self.cfg):
34
+ self.nudge_if_unread(name)
@@ -0,0 +1,47 @@
1
+ """ResourceMonitor — battery/memory/CPU."""
2
+
3
+ from lib.resources import check_battery, check_cpu, check_memory
4
+
5
+ from .base import Concern
6
+ from .config import DispatcherConfig
7
+ from .helpers import board_send, get_dev_sessions, is_claude_running, log, tmux_send
8
+
9
+
10
+ class ResourceMonitor(Concern):
11
+ interval = 60
12
+
13
+ def __init__(self, cfg: DispatcherConfig) -> None:
14
+ super().__init__()
15
+ self.cfg = cfg
16
+ self.last_state = ""
17
+
18
+ def tick(self, now: int) -> None:
19
+ batt = check_battery()
20
+ mem = check_memory()
21
+ cpu = check_cpu()
22
+
23
+ state = f"{batt.status}|{batt.pct}|{mem.status}|{mem.pressure}|{cpu.status}|{cpu.usage}"
24
+ if state == self.last_state:
25
+ return
26
+ self.last_state = state
27
+
28
+ if batt.status == "CRITICAL":
29
+ log(f"RESOURCE: Battery CRITICAL ({batt.pct}%)")
30
+ board_send(self.cfg, "All", f"[Resource] 电池严重不足 ({batt.pct}%),暂停非关键 session。")
31
+ for name in get_dev_sessions(self.cfg):
32
+ sess = f"{self.cfg.prefix}-{name}"
33
+ if is_claude_running(sess):
34
+ tmux_send(sess, "[系统] 电池严重不足,请立即保存状态。")
35
+ elif batt.status == "LOW":
36
+ log(f"RESOURCE: Battery LOW ({batt.pct}%)")
37
+ board_send(self.cfg, "All", f"[Resource] 电池低 ({batt.pct}%),建议减少活跃 session 到 2-3 个。")
38
+
39
+ if mem.status == "CRITICAL":
40
+ log("RESOURCE: Memory pressure CRITICAL")
41
+ board_send(self.cfg, "All", "[Resource] 内存压力严重!建议重启最大的 session 释放内存。")
42
+ elif mem.status == "WARNING":
43
+ log("RESOURCE: Memory pressure WARNING")
44
+ board_send(self.cfg, "All", "[Resource] 内存压力升高,关注中。")
45
+
46
+ if cpu.status == "SATURATED":
47
+ log(f"RESOURCE: CPU saturated ({cpu.usage}%)")