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.
- package/LICENSE +38 -0
- package/Makefile +60 -0
- package/README.md +63 -0
- package/VERSION +1 -0
- package/bin/_pip_entry.py +25 -0
- package/bin/board +287 -0
- package/bin/cnb +150 -0
- package/bin/cnb.js +33 -0
- package/bin/dispatcher +151 -0
- package/bin/dispatcher-watchdog +57 -0
- package/bin/doctor +328 -0
- package/bin/init +316 -0
- package/bin/registry +347 -0
- package/bin/swarm +896 -0
- package/lib/__init__.py +1 -0
- package/lib/board_admin.py +128 -0
- package/lib/board_bbs.py +99 -0
- package/lib/board_bug.py +161 -0
- package/lib/board_db.py +262 -0
- package/lib/board_lock.py +113 -0
- package/lib/board_mailbox.py +145 -0
- package/lib/board_maintenance.py +237 -0
- package/lib/board_msg.py +230 -0
- package/lib/board_task.py +200 -0
- package/lib/board_view.py +366 -0
- package/lib/board_vote.py +164 -0
- package/lib/build_lock.py +221 -0
- package/lib/cli.py +34 -0
- package/lib/common.py +285 -0
- package/lib/concerns/__init__.py +42 -0
- package/lib/concerns/adaptive_throttle.py +26 -0
- package/lib/concerns/base.py +25 -0
- package/lib/concerns/bug_sla_checker.py +32 -0
- package/lib/concerns/config.py +22 -0
- package/lib/concerns/coral_manager.py +61 -0
- package/lib/concerns/coral_poker.py +57 -0
- package/lib/concerns/file_watcher.py +127 -0
- package/lib/concerns/health_checker.py +72 -0
- package/lib/concerns/helpers.py +152 -0
- package/lib/concerns/idle_detector.py +56 -0
- package/lib/concerns/idle_killer.py +41 -0
- package/lib/concerns/idle_nudger.py +38 -0
- package/lib/concerns/inbox_nudger.py +34 -0
- package/lib/concerns/resource_monitor.py +47 -0
- package/lib/concerns/session_keepalive.py +23 -0
- package/lib/concerns/time_announcer.py +34 -0
- package/lib/crypto.py +92 -0
- package/lib/health.py +187 -0
- package/lib/inject.py +164 -0
- package/lib/migrate.py +109 -0
- package/lib/monitor.py +373 -0
- package/lib/panel.py +137 -0
- package/lib/resources.py +341 -0
- package/migrations/001_foreign_keys.sql +77 -0
- package/migrations/002_session_persona.sql +1 -0
- package/migrations/003_mailbox.sql +9 -0
- package/package.json +28 -0
- package/pyproject.toml +71 -0
- package/registry/0001-meridian.json +12 -0
- package/registry/0002-forge.json +12 -0
- package/registry/0003-lead.json +12 -0
- package/registry/0004-ms-encrypted-mailbox-live.json +12 -0
- package/registry/GENESIS.json +9 -0
- package/registry/pubkeys.json +5 -0
- 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}%)")
|