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,266 @@
1
+ """swarm_backend — terminal multiplexer backends (tmux, screen)."""
2
+
3
+ import os
4
+ import re
5
+ import shutil
6
+ import subprocess
7
+ import time
8
+ from abc import ABC, abstractmethod
9
+ from pathlib import Path
10
+
11
+
12
+ class SessionBackend(ABC):
13
+ """Abstract base for terminal multiplexer backends."""
14
+
15
+ @abstractmethod
16
+ def is_running(self, prefix: str, name: str) -> bool: ...
17
+
18
+ @abstractmethod
19
+ def start_session(
20
+ self,
21
+ prefix: str,
22
+ name: str,
23
+ project_root: Path,
24
+ agent_cmd: str,
25
+ ) -> str: ...
26
+
27
+ @abstractmethod
28
+ def stop_session(self, prefix: str, name: str, save_cmd: str) -> None: ...
29
+
30
+ @abstractmethod
31
+ def status_line(self, prefix: str, name: str, agent: str) -> str: ...
32
+
33
+ @abstractmethod
34
+ def attach(self, prefix: str, name: str) -> None: ...
35
+
36
+ @abstractmethod
37
+ def inject(self, prefix: str, name: str, message: str) -> None: ...
38
+
39
+ @abstractmethod
40
+ def capture_pane(self, prefix: str, name: str) -> str: ...
41
+
42
+
43
+ class TmuxBackend(SessionBackend):
44
+ def _sess(self, prefix: str, name: str) -> str:
45
+ return f"{prefix}-{name}"
46
+
47
+ def is_running(self, prefix: str, name: str) -> bool:
48
+ r = subprocess.run(
49
+ ["tmux", "has-session", "-t", self._sess(prefix, name)],
50
+ capture_output=True,
51
+ timeout=5,
52
+ )
53
+ return r.returncode == 0
54
+
55
+ def capture_pane(self, prefix: str, name: str) -> str:
56
+ r = subprocess.run(
57
+ ["tmux", "capture-pane", "-t", self._sess(prefix, name), "-p"],
58
+ capture_output=True,
59
+ text=True,
60
+ timeout=5,
61
+ )
62
+ return r.stdout if r.returncode == 0 else ""
63
+
64
+ def wait_for_shell(self, prefix: str, name: str, timeout: int = 15) -> bool:
65
+ waited = 0
66
+ while waited < timeout:
67
+ text = self.capture_pane(prefix, name)
68
+ if "$" in text or "%" in text or "❯" in text:
69
+ return True
70
+ time.sleep(1)
71
+ waited += 1
72
+ return False
73
+
74
+ def wait_for_prompt(self, prefix: str, name: str, timeout: int = 60) -> bool:
75
+ waited = 0
76
+ while waited < timeout:
77
+ text = self.capture_pane(prefix, name)
78
+ if "❯" in text:
79
+ return True
80
+ time.sleep(2)
81
+ waited += 2
82
+ return False
83
+
84
+ def auto_accept_trust(self, prefix: str, name: str) -> None:
85
+ sess = self._sess(prefix, name)
86
+ waited = 0
87
+ while waited < 60:
88
+ r = subprocess.run(
89
+ ["tmux", "capture-pane", "-t", sess, "-p"],
90
+ capture_output=True,
91
+ text=True,
92
+ timeout=5,
93
+ )
94
+ if "I trust" in r.stdout or "trust this folder" in r.stdout.lower():
95
+ time.sleep(0.5)
96
+ subprocess.run(["tmux", "send-keys", "-t", sess, "Enter"])
97
+ return
98
+ time.sleep(2)
99
+ waited += 2
100
+
101
+ def start_session(
102
+ self,
103
+ prefix: str,
104
+ name: str,
105
+ project_root: Path,
106
+ agent_cmd: str,
107
+ ) -> str:
108
+ sess = self._sess(prefix, name)
109
+ subprocess.run(["tmux", "new-session", "-d", "-s", sess, "-x", "200", "-y", "50"])
110
+ self.wait_for_shell(prefix, name, timeout=10)
111
+ subprocess.run(
112
+ [
113
+ "tmux",
114
+ "send-keys",
115
+ "-t",
116
+ sess,
117
+ f"source ~/.zprofile 2>/dev/null; source ~/.zshrc 2>/dev/null; cd '{project_root}'",
118
+ "Enter",
119
+ ]
120
+ )
121
+ self.wait_for_shell(prefix, name, timeout=10)
122
+ subprocess.run(["tmux", "send-keys", "-t", sess, agent_cmd, "Enter"])
123
+ return sess
124
+
125
+ def stop_session(self, prefix: str, name: str, save_cmd: str) -> None:
126
+ sess = self._sess(prefix, name)
127
+ subprocess.run(["tmux", "send-keys", "-t", sess, "C-c"])
128
+ time.sleep(1)
129
+ subprocess.run(["tmux", "send-keys", "-t", sess, f"! {save_cmd}", "Enter"])
130
+ time.sleep(3)
131
+ subprocess.run(["tmux", "send-keys", "-t", sess, "/exit", "Enter"])
132
+
133
+ waited = 0
134
+ while self.is_running(prefix, name) and waited < 15:
135
+ time.sleep(1)
136
+ waited += 1
137
+ if self.is_running(prefix, name):
138
+ subprocess.run(["tmux", "kill-session", "-t", sess])
139
+ print(f" {name}: force killed (after {waited}s)")
140
+ else:
141
+ print(f" {name}: exited gracefully")
142
+
143
+ def status_line(self, prefix: str, name: str, agent: str) -> str:
144
+ return f"running (tmux, agent: {agent})"
145
+
146
+ def attach(self, prefix: str, name: str) -> None:
147
+ os.execvp("tmux", ["tmux", "attach-session", "-t", self._sess(prefix, name)])
148
+
149
+ def inject(self, prefix: str, name: str, message: str) -> None:
150
+ sess = self._sess(prefix, name)
151
+ if not self.is_running(prefix, name):
152
+ print(f" {name}: not running")
153
+ raise SystemExit(1)
154
+ oneline = message.replace("\n", " ")
155
+ subprocess.run(["tmux", "send-keys", "-t", sess, "-l", oneline])
156
+ subprocess.run(["tmux", "send-keys", "-t", sess, "Enter"])
157
+ print(f" {name}: injected")
158
+
159
+ def inject_initial_prompt(self, prefix: str, name: str, prompt: str, log_dir: Path) -> None:
160
+ if self.wait_for_prompt(prefix, name, timeout=60):
161
+ sess = self._sess(prefix, name)
162
+ time.sleep(1)
163
+ subprocess.run(["tmux", "send-keys", "-t", sess, "-l", prompt])
164
+ subprocess.run(["tmux", "send-keys", "-t", sess, "Enter"])
165
+ else:
166
+ with open(log_dir / f"{name}.log", "a") as f:
167
+ f.write(f"[WARN] {name}: prompt not detected after 60s, skipping injection\n")
168
+
169
+ def enable_mouse(self) -> None:
170
+ subprocess.run(["tmux", "set", "-g", "mouse", "on"], capture_output=True)
171
+
172
+
173
+ class ScreenBackend(SessionBackend):
174
+ def _sess(self, prefix: str, name: str) -> str:
175
+ return f"{prefix}-{name}"
176
+
177
+ def is_running(self, prefix: str, name: str) -> bool:
178
+ sess_tag = f".{self._sess(prefix, name)}"
179
+ r = subprocess.run(["screen", "-list"], capture_output=True, text=True, timeout=5)
180
+ output = r.stdout + r.stderr
181
+ return bool(re.search(rf"{re.escape(sess_tag)}\s", output))
182
+
183
+ def capture_pane(self, prefix: str, name: str) -> str:
184
+ return ""
185
+
186
+ def start_session(
187
+ self,
188
+ prefix: str,
189
+ name: str,
190
+ project_root: Path,
191
+ agent_cmd: str,
192
+ ) -> str:
193
+ sess = self._sess(prefix, name)
194
+ subprocess.run(["screen", "-dmS", sess])
195
+ time.sleep(1)
196
+ subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", f"cd '{project_root}'"])
197
+ subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", "\r"])
198
+ time.sleep(0.5)
199
+ subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", agent_cmd])
200
+ subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", "\r"])
201
+ return sess
202
+
203
+ def stop_session(self, prefix: str, name: str, save_cmd: str) -> None:
204
+ sess = self._sess(prefix, name)
205
+ subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", "\x03"])
206
+ time.sleep(1)
207
+ subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", f"! {save_cmd}\r"])
208
+ time.sleep(3)
209
+ subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", "/exit\r"])
210
+
211
+ waited = 0
212
+ while self.is_running(prefix, name) and waited < 15:
213
+ time.sleep(1)
214
+ waited += 1
215
+ if self.is_running(prefix, name):
216
+ subprocess.run(["screen", "-S", sess, "-X", "quit"])
217
+ print(f" {name}: force killed (after {waited}s)")
218
+ else:
219
+ print(f" {name}: exited gracefully")
220
+
221
+ def status_line(self, prefix: str, name: str, agent: str) -> str:
222
+ r = subprocess.run(["screen", "-list"], capture_output=True, text=True, timeout=5)
223
+ output = r.stdout + r.stderr
224
+ state = ""
225
+ for line in output.splitlines():
226
+ if f".{self._sess(prefix, name)}" in line:
227
+ parts = line.strip().split()
228
+ if parts:
229
+ state = parts[-1]
230
+ break
231
+ return f"running (screen, agent: {agent}) {state}"
232
+
233
+ def attach(self, prefix: str, name: str) -> None:
234
+ os.execvp("screen", ["screen", "-r", self._sess(prefix, name)])
235
+
236
+ def inject(self, prefix: str, name: str, message: str) -> None:
237
+ sess = self._sess(prefix, name)
238
+ if not self.is_running(prefix, name):
239
+ print(f" {name}: not running")
240
+ raise SystemExit(1)
241
+ oneline = message.replace("\n", " ")
242
+ subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", oneline])
243
+ time.sleep(0.3)
244
+ subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", "\r"])
245
+ print(f" {name}: injected")
246
+
247
+ def inject_initial_prompt(self, prefix: str, name: str, prompt: str, log_dir: Path) -> None:
248
+ time.sleep(3)
249
+ sess = self._sess(prefix, name)
250
+ subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", prompt])
251
+ time.sleep(0.3)
252
+ subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", "\r"])
253
+
254
+
255
+ def detect_backend() -> SessionBackend:
256
+ override = os.environ.get("SWARM_MODE", "")
257
+ if override == "tmux":
258
+ return TmuxBackend()
259
+ if override == "screen":
260
+ return ScreenBackend()
261
+ if shutil.which("tmux"):
262
+ return TmuxBackend()
263
+ if shutil.which("screen"):
264
+ return ScreenBackend()
265
+ print("ERROR: neither tmux nor screen found. Install one: brew install tmux")
266
+ raise SystemExit(1)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-nb",
3
- "version": "0.3.0",
3
+ "version": "0.4.0",
4
4
  "description": "Multi-agent coordination framework for Claude Code sessions",
5
5
  "bin": {
6
6
  "cnb": "./bin/cnb.js"
package/pyproject.toml CHANGED
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "claude-nb"
7
- version = "0.3.0"
7
+ version = "0.4.0"
8
8
  description = "Multi-agent coordination framework for Claude Code sessions"
9
9
  requires-python = ">=3.11"
10
10
  license = "OpenAll-1.0"
@@ -1,5 +1,6 @@
1
1
  {
2
2
  "meridian": "3f9baebbfe2ddd36e0ccd6f98301fd1bdc57aa82f395791a769864078181c43c",
3
3
  "musk": "16e74dfbbd797fe17a8e44d99b71d207c8e3aa1b36ffbf2dfe88420dc54bf37c",
4
- "lead": "51b6ebbbe720745bcb9386e1d997481bd265beac45d183fc2ac11fae9824c43a"
4
+ "lead": "51b6ebbbe720745bcb9386e1d997481bd265beac45d183fc2ac11fae9824c43a",
5
+ "sutskever": "e00b9ede0b97993105ec9f6f7ff40aebe545081534050d2d719623cf1f8fd946"
5
6
  }
@@ -1,32 +0,0 @@
1
- """BugSLAChecker — check overdue bugs."""
2
-
3
- import subprocess
4
-
5
- from .base import Concern
6
- from .config import DispatcherConfig
7
- from .coral_poker import CoralPoker
8
- from .helpers import log
9
-
10
-
11
- class BugSLAChecker(Concern):
12
- interval = 600
13
-
14
- def __init__(self, cfg: DispatcherConfig, poker: CoralPoker) -> None:
15
- super().__init__()
16
- self.cfg = cfg
17
- self.poker = poker
18
-
19
- def tick(self, now: int) -> None:
20
- try:
21
- r = subprocess.run(
22
- [self.cfg.board_sh, "bug", "overdue"],
23
- capture_output=True,
24
- text=True,
25
- timeout=10,
26
- )
27
- overdue = r.stdout.strip()
28
- except Exception:
29
- return
30
- if overdue and "No overdue" not in overdue:
31
- log(f"Bug SLA alert: {overdue}")
32
- self.poker.poke(f"[Dispatcher] Bug SLA 超时: {overdue}")
@@ -1,57 +0,0 @@
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')}")
@@ -1,72 +0,0 @@
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
@@ -1,56 +0,0 @@
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)
@@ -1,41 +0,0 @@
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)
@@ -1,38 +0,0 @@
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
@@ -1,34 +0,0 @@
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)
@@ -1,47 +0,0 @@
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}%)")