claude-nb 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/Makefile +8 -2
- package/README.md +40 -56
- package/VERSION +1 -1
- package/bin/board +102 -34
- package/bin/cnb +59 -33
- package/bin/dispatcher +25 -11
- package/bin/doctor +3 -5
- package/bin/init +8 -8
- package/bin/notify +224 -0
- package/bin/registry +8 -23
- package/bin/sync-version +131 -0
- package/lib/board_admin.py +19 -9
- package/lib/board_bbs.py +23 -8
- package/lib/board_bug.py +2 -1
- package/lib/board_db.py +18 -6
- package/lib/board_lock.py +5 -0
- package/lib/board_mailbox.py +6 -4
- package/lib/board_msg.py +112 -24
- package/lib/board_pending.py +233 -0
- package/lib/board_pulse.py +14 -0
- package/lib/board_task.py +22 -10
- package/lib/board_tui.py +28 -20
- package/lib/board_view.py +60 -28
- package/lib/board_vote.py +9 -3
- package/lib/build_lock.py +7 -7
- package/lib/common.py +45 -3
- package/lib/concerns/__init__.py +4 -1
- package/lib/concerns/coral.py +1 -1
- package/lib/concerns/digest_scheduler.py +109 -0
- package/lib/concerns/file_watcher.py +73 -68
- package/lib/concerns/health.py +1 -1
- package/lib/concerns/notification_push.py +171 -0
- package/lib/concerns/notifications.py +58 -3
- package/lib/concerns/nudge_coordinator.py +148 -0
- package/lib/digest.py +62 -0
- package/lib/health.py +2 -2
- package/lib/inject.py +2 -2
- package/lib/monitor.py +8 -4
- package/lib/notification_config.py +101 -0
- package/lib/swarm.py +43 -35
- package/lib/swarm_backend.py +63 -29
- package/lib/theme_profiles.py +89 -0
- package/migrations/004_heartbeat.sql +1 -0
- package/migrations/005_notification_log.sql +12 -0
- package/migrations/006_pending_actions.sql +15 -0
- package/package.json +4 -3
- package/pyproject.toml +3 -2
- package/registry/README.md +9 -0
- package/schema.sql +29 -1
package/lib/monitor.py
CHANGED
|
@@ -75,6 +75,7 @@ class KqueueWatcher:
|
|
|
75
75
|
path = os.path.join(self.watch_dir, f)
|
|
76
76
|
if path in self.file_fds:
|
|
77
77
|
continue
|
|
78
|
+
fd = -1
|
|
78
79
|
try:
|
|
79
80
|
fd = os.open(path, os.O_RDONLY)
|
|
80
81
|
ev = select.kevent(
|
|
@@ -86,7 +87,8 @@ class KqueueWatcher:
|
|
|
86
87
|
self.kq.control([ev], 0)
|
|
87
88
|
self.file_fds[path] = fd
|
|
88
89
|
except OSError:
|
|
89
|
-
|
|
90
|
+
if fd >= 0:
|
|
91
|
+
os.close(fd)
|
|
90
92
|
|
|
91
93
|
def poll(self, timeout: float = 5.0) -> set:
|
|
92
94
|
"""Block up to *timeout* seconds, return set of changed file paths."""
|
|
@@ -144,11 +146,13 @@ class InotifyWatcher:
|
|
|
144
146
|
import selectors
|
|
145
147
|
|
|
146
148
|
sel = selectors.DefaultSelector()
|
|
147
|
-
|
|
149
|
+
stdout = self.proc.stdout
|
|
150
|
+
assert stdout is not None
|
|
151
|
+
sel.register(stdout, selectors.EVENT_READ)
|
|
148
152
|
changed = set()
|
|
149
153
|
events = sel.select(timeout=timeout)
|
|
150
154
|
for key, _ in events:
|
|
151
|
-
line = key.fileobj.readline().strip()
|
|
155
|
+
line = key.fileobj.readline().strip() # type: ignore[union-attr]
|
|
152
156
|
if line:
|
|
153
157
|
changed.add(line)
|
|
154
158
|
sel.close()
|
|
@@ -349,7 +353,7 @@ def main() -> None:
|
|
|
349
353
|
print(" --benchmark Compare event vs polling")
|
|
350
354
|
else:
|
|
351
355
|
print(f"Unknown: {arg}", file=sys.stderr)
|
|
352
|
-
|
|
356
|
+
raise SystemExit(1)
|
|
353
357
|
|
|
354
358
|
|
|
355
359
|
if __name__ == "__main__":
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
"""notification_config — parse .claudes/notifications.toml for push subscriptions."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tomllib
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
NOTIFICATION_TYPES = ("daily-digest", "ci-alert", "mention", "issue-activity", "weekly-report")
|
|
10
|
+
CHANNELS = ("board-inbox", "lark-im", "lark-mail", "gmail")
|
|
11
|
+
|
|
12
|
+
BUILTIN_DEFAULTS: dict[str, bool] = {
|
|
13
|
+
"daily-digest": True,
|
|
14
|
+
"ci-alert": True,
|
|
15
|
+
"mention": True,
|
|
16
|
+
"issue-activity": False,
|
|
17
|
+
"weekly-report": False,
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class HumanRecipient:
|
|
23
|
+
name: str
|
|
24
|
+
email: str
|
|
25
|
+
subscriptions: dict[str, bool] = field(default_factory=dict)
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@dataclass
|
|
29
|
+
class NotificationConfig:
|
|
30
|
+
defaults: dict[str, bool] = field(default_factory=dict)
|
|
31
|
+
human_channel: str = "lark-im"
|
|
32
|
+
teammate_channel: str = "board-inbox"
|
|
33
|
+
overrides: dict[str, dict[str, bool]] = field(default_factory=dict)
|
|
34
|
+
human: HumanRecipient | None = None
|
|
35
|
+
|
|
36
|
+
def is_subscribed(self, member: str, notif_type: str) -> bool:
|
|
37
|
+
if notif_type not in NOTIFICATION_TYPES:
|
|
38
|
+
return False
|
|
39
|
+
if member in self.overrides and notif_type in self.overrides[member]:
|
|
40
|
+
return self.overrides[member][notif_type]
|
|
41
|
+
return self.defaults.get(notif_type, BUILTIN_DEFAULTS.get(notif_type, False))
|
|
42
|
+
|
|
43
|
+
def channel_for(self, member: str) -> str:
|
|
44
|
+
if self.human and member == "human":
|
|
45
|
+
return self.human_channel
|
|
46
|
+
return self.teammate_channel
|
|
47
|
+
|
|
48
|
+
def subscribers_for(self, notif_type: str, members: list[str]) -> list[str]:
|
|
49
|
+
return [m for m in members if self.is_subscribed(m, notif_type)]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def load(config_path: Path) -> NotificationConfig:
|
|
53
|
+
if not config_path.exists():
|
|
54
|
+
return NotificationConfig(defaults=dict(BUILTIN_DEFAULTS))
|
|
55
|
+
|
|
56
|
+
with open(config_path, "rb") as f:
|
|
57
|
+
data = tomllib.load(f)
|
|
58
|
+
|
|
59
|
+
defaults = dict(BUILTIN_DEFAULTS)
|
|
60
|
+
if "defaults" in data:
|
|
61
|
+
for k, v in data["defaults"].items():
|
|
62
|
+
if k in NOTIFICATION_TYPES and isinstance(v, bool):
|
|
63
|
+
defaults[k] = v
|
|
64
|
+
|
|
65
|
+
channel_cfg = data.get("channel", {})
|
|
66
|
+
human_channel = channel_cfg.get("human", "lark-im")
|
|
67
|
+
teammate_channel = channel_cfg.get("teammate", "board-inbox")
|
|
68
|
+
|
|
69
|
+
if human_channel not in CHANNELS:
|
|
70
|
+
human_channel = "lark-im"
|
|
71
|
+
if teammate_channel not in CHANNELS:
|
|
72
|
+
teammate_channel = "board-inbox"
|
|
73
|
+
|
|
74
|
+
overrides: dict[str, dict[str, bool]] = {}
|
|
75
|
+
for member, prefs in data.get("override", {}).items():
|
|
76
|
+
member_lower = member.lower()
|
|
77
|
+
overrides[member_lower] = {}
|
|
78
|
+
for k, v in prefs.items():
|
|
79
|
+
if k in NOTIFICATION_TYPES and isinstance(v, bool):
|
|
80
|
+
overrides[member_lower][k] = v
|
|
81
|
+
|
|
82
|
+
human = None
|
|
83
|
+
if "human" in data:
|
|
84
|
+
h = data["human"]
|
|
85
|
+
human_subs = {}
|
|
86
|
+
for k in NOTIFICATION_TYPES:
|
|
87
|
+
if k in h and isinstance(h[k], bool):
|
|
88
|
+
human_subs[k] = h[k]
|
|
89
|
+
human = HumanRecipient(
|
|
90
|
+
name=h.get("name", ""),
|
|
91
|
+
email=h.get("email", ""),
|
|
92
|
+
subscriptions=human_subs,
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
return NotificationConfig(
|
|
96
|
+
defaults=defaults,
|
|
97
|
+
human_channel=human_channel,
|
|
98
|
+
teammate_channel=teammate_channel,
|
|
99
|
+
overrides=overrides,
|
|
100
|
+
human=human,
|
|
101
|
+
)
|
package/lib/swarm.py
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
"""swarm — launch and manage multi-agent sessions."""
|
|
2
2
|
|
|
3
3
|
import os
|
|
4
|
-
import re
|
|
5
4
|
import threading
|
|
5
|
+
import time
|
|
6
|
+
import tomllib
|
|
6
7
|
from dataclasses import dataclass
|
|
7
8
|
from datetime import datetime
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
|
|
10
11
|
from lib.board_db import BoardDB
|
|
11
|
-
from lib.common import ClaudesEnv, is_suspended
|
|
12
|
+
from lib.common import ClaudesEnv, _write_config_toml, is_suspended
|
|
12
13
|
from lib.swarm_backend import SessionBackend, TmuxBackend, detect_backend
|
|
14
|
+
from lib.theme_profiles import PROFILES
|
|
13
15
|
|
|
14
16
|
|
|
15
17
|
@dataclass
|
|
@@ -29,6 +31,13 @@ class SwarmConfig:
|
|
|
29
31
|
return cls(env=env, agent=agent, backend=backend, install_home=install_home)
|
|
30
32
|
|
|
31
33
|
|
|
34
|
+
def _lookup_profile(name: str) -> dict[str, str] | None:
|
|
35
|
+
for theme_profiles in PROFILES.values():
|
|
36
|
+
if name in theme_profiles:
|
|
37
|
+
return theme_profiles[name]
|
|
38
|
+
return None
|
|
39
|
+
|
|
40
|
+
|
|
32
41
|
class SwarmManager:
|
|
33
42
|
def __init__(self, cfg: SwarmConfig) -> None:
|
|
34
43
|
self.cfg = cfg
|
|
@@ -43,8 +52,13 @@ class SwarmManager:
|
|
|
43
52
|
|
|
44
53
|
def build_system_prompt(self, name: str) -> str:
|
|
45
54
|
board = self._board_path()
|
|
55
|
+
profile = _lookup_profile(name)
|
|
56
|
+
identity = f"你是 {name}"
|
|
57
|
+
if profile:
|
|
58
|
+
identity += f"({profile['full_name']} — {profile['info']})"
|
|
59
|
+
identity += ",cnb 团队的一员。你在后台工作,通过消息板与组长和同学协作。\n"
|
|
46
60
|
return (
|
|
47
|
-
f"
|
|
61
|
+
f"{identity}"
|
|
48
62
|
f"协作命令:\n"
|
|
49
63
|
f" {board} --as {name} inbox # 查看收件箱\n"
|
|
50
64
|
f" {board} --as {name} ack # 清空收件箱\n"
|
|
@@ -113,15 +127,17 @@ class SwarmManager:
|
|
|
113
127
|
|
|
114
128
|
config_path = self._env.claudes_dir / "config.toml"
|
|
115
129
|
if config_path.exists():
|
|
116
|
-
|
|
130
|
+
data = tomllib.loads(config_path.read_text())
|
|
131
|
+
current_sessions = list(data.get("sessions", []))
|
|
117
132
|
changed = False
|
|
118
133
|
for n in names:
|
|
119
|
-
if
|
|
120
|
-
|
|
121
|
-
|
|
134
|
+
if n not in current_sessions:
|
|
135
|
+
current_sessions.append(n)
|
|
136
|
+
data.setdefault("session", {})[n] = {"persona": ""}
|
|
122
137
|
changed = True
|
|
123
138
|
if changed:
|
|
124
|
-
|
|
139
|
+
data["sessions"] = current_sessions
|
|
140
|
+
_write_config_toml(config_path, data)
|
|
125
141
|
self._env.sessions.extend([n for n in names if n not in self._env.sessions])
|
|
126
142
|
|
|
127
143
|
# --- Attendance ---
|
|
@@ -174,24 +190,15 @@ class SwarmManager:
|
|
|
174
190
|
# --- Role filtering ---
|
|
175
191
|
|
|
176
192
|
def get_role(self, name: str) -> str:
|
|
177
|
-
|
|
178
|
-
if not
|
|
193
|
+
config_path = self._env.claudes_dir / "config.toml"
|
|
194
|
+
if not config_path.exists():
|
|
179
195
|
return "unknown"
|
|
180
196
|
try:
|
|
181
|
-
|
|
182
|
-
except OSError:
|
|
197
|
+
data = tomllib.loads(config_path.read_text())
|
|
198
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
183
199
|
return "unknown"
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
lower = line.lower()
|
|
187
|
-
if "实习生" in lower:
|
|
188
|
-
return "intern"
|
|
189
|
-
if "调度员" in lower:
|
|
190
|
-
return "dispatcher"
|
|
191
|
-
if "lead" in lower:
|
|
192
|
-
return "lead"
|
|
193
|
-
return "dev"
|
|
194
|
-
return "unknown"
|
|
200
|
+
role = data.get("session", {}).get(name, {}).get("role", "")
|
|
201
|
+
return role if role else "unknown"
|
|
195
202
|
|
|
196
203
|
def filter_sessions(self, *, role: str = "", exclude: str = "") -> list[str]:
|
|
197
204
|
result: list[str] = []
|
|
@@ -211,8 +218,11 @@ class SwarmManager:
|
|
|
211
218
|
backend = self.cfg.backend
|
|
212
219
|
|
|
213
220
|
if backend.is_running(prefix, name):
|
|
214
|
-
|
|
215
|
-
|
|
221
|
+
if backend.is_agent_active(prefix, name):
|
|
222
|
+
print(f" {name}: already running")
|
|
223
|
+
return
|
|
224
|
+
backend.stop_session(prefix, name, "true")
|
|
225
|
+
time.sleep(1)
|
|
216
226
|
|
|
217
227
|
self.log_startup(name)
|
|
218
228
|
agent_cmd = self.build_agent_cmd(name)
|
|
@@ -279,7 +289,7 @@ class SwarmManager:
|
|
|
279
289
|
|
|
280
290
|
print()
|
|
281
291
|
backend_name = type(self.cfg.backend).__name__.lower().replace("backend", "")
|
|
282
|
-
print(f"Mode: {backend_name} |
|
|
292
|
+
print(f"Mode: {backend_name} | Engine: {self.cfg.agent} | Started: {started}")
|
|
283
293
|
print(f"Logs: {self._env.log_dir}")
|
|
284
294
|
if isinstance(self.cfg.backend, TmuxBackend):
|
|
285
295
|
print(f" tmux attach -t {self._env.prefix}-<name> # attach (Ctrl-B D to detach)")
|
|
@@ -289,15 +299,17 @@ class SwarmManager:
|
|
|
289
299
|
|
|
290
300
|
def status(self) -> None:
|
|
291
301
|
backend_name = type(self.cfg.backend).__name__.lower().replace("backend", "")
|
|
292
|
-
print(f"===
|
|
302
|
+
print(f"=== 同学状态 (mode: {backend_name}, engine: {self.cfg.agent}) ===")
|
|
293
303
|
prefix = self._env.prefix
|
|
294
304
|
sf = self._env.suspended_file
|
|
295
305
|
for name in self._env.sessions:
|
|
296
306
|
if is_suspended(name, sf):
|
|
297
307
|
print(f" {name}: SUSPENDED")
|
|
298
|
-
elif self.cfg.backend.
|
|
308
|
+
elif self.cfg.backend.is_agent_active(prefix, name):
|
|
299
309
|
line = self.cfg.backend.status_line(prefix, name, self.cfg.agent)
|
|
300
310
|
print(f" {name}: {line}")
|
|
311
|
+
elif self.cfg.backend.is_running(prefix, name):
|
|
312
|
+
print(f" {name}: stale (session exists, 同学已退出)")
|
|
301
313
|
else:
|
|
302
314
|
print(f" {name}: stopped")
|
|
303
315
|
|
|
@@ -367,8 +379,6 @@ class SwarmManager:
|
|
|
367
379
|
continue
|
|
368
380
|
if self.cfg.backend.is_running(prefix, name):
|
|
369
381
|
self.cfg.backend.stop_session(prefix, name, self._save_cmd(name))
|
|
370
|
-
import time
|
|
371
|
-
|
|
372
382
|
time.sleep(1)
|
|
373
383
|
self._start_one(name)
|
|
374
384
|
print(f" {name}: restarted")
|
|
@@ -410,8 +420,6 @@ class SwarmManager:
|
|
|
410
420
|
lines = [l for l in sf.read_text().splitlines() if l != name]
|
|
411
421
|
sf.write_text("\n".join(lines) + "\n" if lines else "")
|
|
412
422
|
self._start_one(name)
|
|
413
|
-
import time
|
|
414
|
-
|
|
415
423
|
time.sleep(1)
|
|
416
424
|
if self.cfg.backend.is_running(prefix, name):
|
|
417
425
|
self.clock_in(name)
|
|
@@ -428,10 +436,10 @@ class SwarmManager:
|
|
|
428
436
|
def help(self) -> None:
|
|
429
437
|
backend_name = type(self.cfg.backend).__name__.lower().replace("backend", "")
|
|
430
438
|
print(f"""\
|
|
431
|
-
swarm —
|
|
439
|
+
swarm — 管理同学协作会话
|
|
432
440
|
|
|
433
441
|
Mode: {backend_name} (override with SWARM_MODE=tmux|screen)
|
|
434
|
-
|
|
442
|
+
Engine: {self.cfg.agent} (override with SWARM_AGENT=claude|trae|qwen)
|
|
435
443
|
|
|
436
444
|
start [names...] Launch sessions (default: all non-suspended)
|
|
437
445
|
start --role=dev Launch only sessions with matching role
|
|
@@ -445,7 +453,7 @@ Agent: {self.cfg.agent} (override with SWARM_AGENT=claude|trae|qwen)
|
|
|
445
453
|
attendance Show attendance records
|
|
446
454
|
help This message
|
|
447
455
|
|
|
448
|
-
Roles (from
|
|
456
|
+
Roles (from config.toml [session.X] role key): lead, dev, intern, dispatcher
|
|
449
457
|
|
|
450
458
|
Examples:
|
|
451
459
|
swarm start # launch all with Claude (default)
|
package/lib/swarm_backend.py
CHANGED
|
@@ -15,6 +15,9 @@ class SessionBackend(ABC):
|
|
|
15
15
|
@abstractmethod
|
|
16
16
|
def is_running(self, prefix: str, name: str) -> bool: ...
|
|
17
17
|
|
|
18
|
+
def is_agent_active(self, prefix: str, name: str) -> bool:
|
|
19
|
+
return self.is_running(prefix, name)
|
|
20
|
+
|
|
18
21
|
@abstractmethod
|
|
19
22
|
def start_session(
|
|
20
23
|
self,
|
|
@@ -39,6 +42,9 @@ class SessionBackend(ABC):
|
|
|
39
42
|
@abstractmethod
|
|
40
43
|
def capture_pane(self, prefix: str, name: str) -> str: ...
|
|
41
44
|
|
|
45
|
+
@abstractmethod
|
|
46
|
+
def inject_initial_prompt(self, prefix: str, name: str, prompt: str, log_dir: Path) -> None: ...
|
|
47
|
+
|
|
42
48
|
|
|
43
49
|
class TmuxBackend(SessionBackend):
|
|
44
50
|
def _sess(self, prefix: str, name: str) -> str:
|
|
@@ -52,6 +58,21 @@ class TmuxBackend(SessionBackend):
|
|
|
52
58
|
)
|
|
53
59
|
return r.returncode == 0
|
|
54
60
|
|
|
61
|
+
def _pane_command(self, prefix: str, name: str) -> str:
|
|
62
|
+
r = subprocess.run(
|
|
63
|
+
["tmux", "list-panes", "-t", self._sess(prefix, name), "-F", "#{pane_current_command}"],
|
|
64
|
+
capture_output=True,
|
|
65
|
+
text=True,
|
|
66
|
+
timeout=5,
|
|
67
|
+
)
|
|
68
|
+
return r.stdout.strip().split("\n")[0] if r.returncode == 0 else ""
|
|
69
|
+
|
|
70
|
+
def is_agent_active(self, prefix: str, name: str) -> bool:
|
|
71
|
+
if not self.is_running(prefix, name):
|
|
72
|
+
return False
|
|
73
|
+
cmd = self._pane_command(prefix, name)
|
|
74
|
+
return cmd not in ("zsh", "bash", "sh", "-zsh", "-bash", "")
|
|
75
|
+
|
|
55
76
|
def capture_pane(self, prefix: str, name: str) -> str:
|
|
56
77
|
r = subprocess.run(
|
|
57
78
|
["tmux", "capture-pane", "-t", self._sess(prefix, name), "-p"],
|
|
@@ -93,7 +114,7 @@ class TmuxBackend(SessionBackend):
|
|
|
93
114
|
)
|
|
94
115
|
if "I trust" in r.stdout or "trust this folder" in r.stdout.lower():
|
|
95
116
|
time.sleep(0.5)
|
|
96
|
-
subprocess.run(["tmux", "send-keys", "-t", sess, "Enter"])
|
|
117
|
+
subprocess.run(["tmux", "send-keys", "-t", sess, "Enter"], timeout=10)
|
|
97
118
|
return
|
|
98
119
|
time.sleep(2)
|
|
99
120
|
waited += 2
|
|
@@ -106,7 +127,7 @@ class TmuxBackend(SessionBackend):
|
|
|
106
127
|
agent_cmd: str,
|
|
107
128
|
) -> str:
|
|
108
129
|
sess = self._sess(prefix, name)
|
|
109
|
-
subprocess.run(["tmux", "new-session", "-d", "-s", sess, "-x", "200", "-y", "50"])
|
|
130
|
+
subprocess.run(["tmux", "new-session", "-d", "-s", sess, "-x", "200", "-y", "50"], timeout=10)
|
|
110
131
|
self.wait_for_shell(prefix, name, timeout=10)
|
|
111
132
|
subprocess.run(
|
|
112
133
|
[
|
|
@@ -114,34 +135,35 @@ class TmuxBackend(SessionBackend):
|
|
|
114
135
|
"send-keys",
|
|
115
136
|
"-t",
|
|
116
137
|
sess,
|
|
117
|
-
f"source ~/.zprofile 2>/dev/null; source ~/.zshrc 2>/dev/null; cd '{project_root}'",
|
|
138
|
+
f"source ~/.zprofile 2>/dev/null; source ~/.zshrc 2>/dev/null; cd '{project_root}' && export CNB_PROJECT='{project_root}'",
|
|
118
139
|
"Enter",
|
|
119
|
-
]
|
|
140
|
+
],
|
|
141
|
+
timeout=10,
|
|
120
142
|
)
|
|
121
143
|
self.wait_for_shell(prefix, name, timeout=10)
|
|
122
|
-
subprocess.run(["tmux", "send-keys", "-t", sess, agent_cmd, "Enter"])
|
|
144
|
+
subprocess.run(["tmux", "send-keys", "-t", sess, agent_cmd, "Enter"], timeout=10)
|
|
123
145
|
return sess
|
|
124
146
|
|
|
125
147
|
def stop_session(self, prefix: str, name: str, save_cmd: str) -> None:
|
|
126
148
|
sess = self._sess(prefix, name)
|
|
127
|
-
subprocess.run(["tmux", "send-keys", "-t", sess, "C-c"])
|
|
149
|
+
subprocess.run(["tmux", "send-keys", "-t", sess, "C-c"], timeout=10)
|
|
128
150
|
time.sleep(1)
|
|
129
|
-
subprocess.run(["tmux", "send-keys", "-t", sess, f"! {save_cmd}", "Enter"])
|
|
151
|
+
subprocess.run(["tmux", "send-keys", "-t", sess, f"! {save_cmd}", "Enter"], timeout=10)
|
|
130
152
|
time.sleep(3)
|
|
131
|
-
subprocess.run(["tmux", "send-keys", "-t", sess, "/exit", "Enter"])
|
|
153
|
+
subprocess.run(["tmux", "send-keys", "-t", sess, "/exit", "Enter"], timeout=10)
|
|
132
154
|
|
|
133
155
|
waited = 0
|
|
134
156
|
while self.is_running(prefix, name) and waited < 15:
|
|
135
157
|
time.sleep(1)
|
|
136
158
|
waited += 1
|
|
137
159
|
if self.is_running(prefix, name):
|
|
138
|
-
subprocess.run(["tmux", "kill-session", "-t", sess])
|
|
160
|
+
subprocess.run(["tmux", "kill-session", "-t", sess], timeout=10)
|
|
139
161
|
print(f" {name}: force killed (after {waited}s)")
|
|
140
162
|
else:
|
|
141
163
|
print(f" {name}: exited gracefully")
|
|
142
164
|
|
|
143
165
|
def status_line(self, prefix: str, name: str, agent: str) -> str:
|
|
144
|
-
return f"running (tmux,
|
|
166
|
+
return f"running (tmux, engine: {agent})"
|
|
145
167
|
|
|
146
168
|
def attach(self, prefix: str, name: str) -> None:
|
|
147
169
|
os.execvp("tmux", ["tmux", "attach-session", "-t", self._sess(prefix, name)])
|
|
@@ -152,22 +174,22 @@ class TmuxBackend(SessionBackend):
|
|
|
152
174
|
print(f" {name}: not running")
|
|
153
175
|
raise SystemExit(1)
|
|
154
176
|
oneline = message.replace("\n", " ")
|
|
155
|
-
subprocess.run(["tmux", "send-keys", "-t", sess, "-l", oneline])
|
|
156
|
-
subprocess.run(["tmux", "send-keys", "-t", sess, "Enter"])
|
|
177
|
+
subprocess.run(["tmux", "send-keys", "-t", sess, "-l", oneline], timeout=10)
|
|
178
|
+
subprocess.run(["tmux", "send-keys", "-t", sess, "Enter"], timeout=10)
|
|
157
179
|
print(f" {name}: injected")
|
|
158
180
|
|
|
159
181
|
def inject_initial_prompt(self, prefix: str, name: str, prompt: str, log_dir: Path) -> None:
|
|
160
182
|
if self.wait_for_prompt(prefix, name, timeout=60):
|
|
161
183
|
sess = self._sess(prefix, name)
|
|
162
184
|
time.sleep(1)
|
|
163
|
-
subprocess.run(["tmux", "send-keys", "-t", sess, "-l", prompt])
|
|
164
|
-
subprocess.run(["tmux", "send-keys", "-t", sess, "Enter"])
|
|
185
|
+
subprocess.run(["tmux", "send-keys", "-t", sess, "-l", prompt], timeout=10)
|
|
186
|
+
subprocess.run(["tmux", "send-keys", "-t", sess, "Enter"], timeout=10)
|
|
165
187
|
else:
|
|
166
188
|
with open(log_dir / f"{name}.log", "a") as f:
|
|
167
189
|
f.write(f"[WARN] {name}: prompt not detected after 60s, skipping injection\n")
|
|
168
190
|
|
|
169
191
|
def enable_mouse(self) -> None:
|
|
170
|
-
subprocess.run(["tmux", "set", "-g", "mouse", "on"], capture_output=True)
|
|
192
|
+
subprocess.run(["tmux", "set", "-g", "mouse", "on"], capture_output=True, timeout=10)
|
|
171
193
|
|
|
172
194
|
|
|
173
195
|
class ScreenBackend(SessionBackend):
|
|
@@ -191,29 +213,41 @@ class ScreenBackend(SessionBackend):
|
|
|
191
213
|
agent_cmd: str,
|
|
192
214
|
) -> str:
|
|
193
215
|
sess = self._sess(prefix, name)
|
|
194
|
-
subprocess.run(["screen", "-dmS", sess])
|
|
216
|
+
subprocess.run(["screen", "-dmS", sess], timeout=10)
|
|
195
217
|
time.sleep(1)
|
|
196
|
-
subprocess.run(
|
|
197
|
-
|
|
218
|
+
subprocess.run(
|
|
219
|
+
[
|
|
220
|
+
"screen",
|
|
221
|
+
"-S",
|
|
222
|
+
sess,
|
|
223
|
+
"-p",
|
|
224
|
+
"0",
|
|
225
|
+
"-X",
|
|
226
|
+
"stuff",
|
|
227
|
+
f"cd '{project_root}' && export CNB_PROJECT='{project_root}'",
|
|
228
|
+
],
|
|
229
|
+
timeout=10,
|
|
230
|
+
)
|
|
231
|
+
subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", "\r"], timeout=10)
|
|
198
232
|
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"])
|
|
233
|
+
subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", agent_cmd], timeout=10)
|
|
234
|
+
subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", "\r"], timeout=10)
|
|
201
235
|
return sess
|
|
202
236
|
|
|
203
237
|
def stop_session(self, prefix: str, name: str, save_cmd: str) -> None:
|
|
204
238
|
sess = self._sess(prefix, name)
|
|
205
|
-
subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", "\x03"])
|
|
239
|
+
subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", "\x03"], timeout=10)
|
|
206
240
|
time.sleep(1)
|
|
207
|
-
subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", f"! {save_cmd}\r"])
|
|
241
|
+
subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", f"! {save_cmd}\r"], timeout=10)
|
|
208
242
|
time.sleep(3)
|
|
209
|
-
subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", "/exit\r"])
|
|
243
|
+
subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", "/exit\r"], timeout=10)
|
|
210
244
|
|
|
211
245
|
waited = 0
|
|
212
246
|
while self.is_running(prefix, name) and waited < 15:
|
|
213
247
|
time.sleep(1)
|
|
214
248
|
waited += 1
|
|
215
249
|
if self.is_running(prefix, name):
|
|
216
|
-
subprocess.run(["screen", "-S", sess, "-X", "quit"])
|
|
250
|
+
subprocess.run(["screen", "-S", sess, "-X", "quit"], timeout=10)
|
|
217
251
|
print(f" {name}: force killed (after {waited}s)")
|
|
218
252
|
else:
|
|
219
253
|
print(f" {name}: exited gracefully")
|
|
@@ -228,7 +262,7 @@ class ScreenBackend(SessionBackend):
|
|
|
228
262
|
if parts:
|
|
229
263
|
state = parts[-1]
|
|
230
264
|
break
|
|
231
|
-
return f"running (screen,
|
|
265
|
+
return f"running (screen, engine: {agent}) {state}"
|
|
232
266
|
|
|
233
267
|
def attach(self, prefix: str, name: str) -> None:
|
|
234
268
|
os.execvp("screen", ["screen", "-r", self._sess(prefix, name)])
|
|
@@ -239,17 +273,17 @@ class ScreenBackend(SessionBackend):
|
|
|
239
273
|
print(f" {name}: not running")
|
|
240
274
|
raise SystemExit(1)
|
|
241
275
|
oneline = message.replace("\n", " ")
|
|
242
|
-
subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", oneline])
|
|
276
|
+
subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", oneline], timeout=10)
|
|
243
277
|
time.sleep(0.3)
|
|
244
|
-
subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", "\r"])
|
|
278
|
+
subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", "\r"], timeout=10)
|
|
245
279
|
print(f" {name}: injected")
|
|
246
280
|
|
|
247
281
|
def inject_initial_prompt(self, prefix: str, name: str, prompt: str, log_dir: Path) -> None:
|
|
248
282
|
time.sleep(3)
|
|
249
283
|
sess = self._sess(prefix, name)
|
|
250
|
-
subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", prompt])
|
|
284
|
+
subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", prompt], timeout=10)
|
|
251
285
|
time.sleep(0.3)
|
|
252
|
-
subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", "\r"])
|
|
286
|
+
subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", "\r"], timeout=10)
|
|
253
287
|
|
|
254
288
|
|
|
255
289
|
def detect_backend() -> SessionBackend:
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""Profile data for person-name themes.
|
|
2
|
+
|
|
3
|
+
Maps canonical short name -> full name + info for disambiguation.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
PROFILES: dict[str, dict[str, dict[str, str]]] = {
|
|
7
|
+
"ai": {
|
|
8
|
+
"altman": {"full_name": "Sam Altman", "info": "CEO of OpenAI"},
|
|
9
|
+
"dario": {"full_name": "Dario Amodei", "info": "CEO of Anthropic"},
|
|
10
|
+
"ilya": {
|
|
11
|
+
"full_name": "Ilya Sutskever",
|
|
12
|
+
"zh": "伊利亚",
|
|
13
|
+
"info": "Co-founder of SSI, former OpenAI chief scientist",
|
|
14
|
+
},
|
|
15
|
+
"lecun": {"full_name": "Yann LeCun", "info": "Chief AI Scientist at Meta, Turing Award winner"},
|
|
16
|
+
"karpathy": {"full_name": "Andrej Karpathy", "info": "Founder of Eureka Labs, former Tesla AI director"},
|
|
17
|
+
"hassabis": {"full_name": "Demis Hassabis", "info": "CEO of Google DeepMind, Nobel laureate"},
|
|
18
|
+
"vaswani": {
|
|
19
|
+
"full_name": "Ashish Vaswani",
|
|
20
|
+
"info": "Lead author of Attention Is All You Need, Transformer inventor",
|
|
21
|
+
},
|
|
22
|
+
"hinton": {"full_name": "Geoffrey Hinton", "info": "Godfather of deep learning, Turing & Nobel laureate"},
|
|
23
|
+
"bengio": {"full_name": "Yoshua Bengio", "info": "Mila founder, Turing Award winner"},
|
|
24
|
+
"fei-fei": {"full_name": "Fei-Fei Li", "zh": "李飞飞", "info": "Stanford HAI co-director, ImageNet creator"},
|
|
25
|
+
"ng": {"full_name": "Andrew Ng", "zh": "吴恩达", "info": "DeepLearning.AI founder, former Baidu VP"},
|
|
26
|
+
"zuck": {"full_name": "Mark Zuckerberg", "info": "CEO of Meta"},
|
|
27
|
+
"bezos": {"full_name": "Jeff Bezos", "info": "Founder of Amazon & Blue Origin"},
|
|
28
|
+
"nadella": {"full_name": "Satya Nadella", "info": "CEO of Microsoft"},
|
|
29
|
+
"pichai": {"full_name": "Sundar Pichai", "info": "CEO of Google & Alphabet"},
|
|
30
|
+
"musk": {"full_name": "Elon Musk", "info": "CEO of Tesla, SpaceX, xAI"},
|
|
31
|
+
"huang": {"full_name": "Jensen Huang", "zh": "黄仁勋", "info": "CEO of NVIDIA"},
|
|
32
|
+
"lisa-su": {"full_name": "Lisa Su", "zh": "苏姿丰", "info": "CEO of AMD"},
|
|
33
|
+
"radford": {"full_name": "Alec Radford", "info": "GPT & CLIP author, OpenAI researcher"},
|
|
34
|
+
"jack-clark": {"full_name": "Jack Clark", "info": "Co-founder of Anthropic"},
|
|
35
|
+
},
|
|
36
|
+
"threebody": {
|
|
37
|
+
"luo-ji": {"full_name": "罗辑 (Luo Ji)", "info": "面壁者、执剑人,黑暗森林威慑的守护者"},
|
|
38
|
+
"shi-qiang": {"full_name": "史强 (Shi Qiang)", "info": "刑警,人称大史,罗辑的搭档与守护者"},
|
|
39
|
+
"ye-wenjie": {"full_name": "叶文洁 (Ye Wenjie)", "info": "天体物理学家,红岸工程负责人,三体危机的起源"},
|
|
40
|
+
"cheng-xin": {"full_name": "程心 (Cheng Xin)", "info": "航天工程师,第二任执剑人,阶梯计划发起者"},
|
|
41
|
+
"zhang-beihai": {
|
|
42
|
+
"full_name": "章北海 (Zhang Beihai)",
|
|
43
|
+
"info": "海军政委,面壁者式的钢印战士,自然选择号劫持者",
|
|
44
|
+
},
|
|
45
|
+
"yun-tianming": {
|
|
46
|
+
"full_name": "云天明 (Yun Tianming)",
|
|
47
|
+
"info": "通过阶梯计划将大脑送往三体舰队,用三个童话传递情报",
|
|
48
|
+
},
|
|
49
|
+
"wang-miao": {"full_name": "汪淼 (Wang Miao)", "info": "纳米材料学家,三体游戏玩家,古筝行动参与者"},
|
|
50
|
+
"ding-yi": {"full_name": "丁仪 (Ding Yi)", "info": "理论物理学家,研究球状闪电和宏原子"},
|
|
51
|
+
"yang-dong": {"full_name": "杨冬 (Yang Dong)", "info": "粒子物理学家,叶文洁之女,因物理学不存在而绝望"},
|
|
52
|
+
"zhuang-yan": {"full_name": "庄颜 (Zhuang Yan)", "info": "罗辑的妻子,梦中人变为现实"},
|
|
53
|
+
"guan-yifan": {"full_name": "关一帆 (Guan Yifan)", "info": "引力波宇宙学家,与程心一同进入小宇宙"},
|
|
54
|
+
"shen-yufei": {"full_name": "申玉菲 (Shen Yufei)", "info": "物理学家,三体组织成员,降临派"},
|
|
55
|
+
"wei-cheng": {"full_name": "魏成 (Wei Cheng)", "info": "数学家,申玉菲之夫,研究三体问题数学解"},
|
|
56
|
+
"hines": {"full_name": "Bill Hines (比尔·希恩斯)", "info": "面壁者,脑科学家,思想钢印发明者"},
|
|
57
|
+
"tyler": {"full_name": "Frederick Tyler (弗雷德里克·泰勒)", "info": "面壁者,前美国国防部长"},
|
|
58
|
+
"wade": {"full_name": "Thomas Wade (托马斯·维德)", "info": "PIA 局长,光速飞船的推动者,不择手段的战略家"},
|
|
59
|
+
"evans": {"full_name": "Mike Evans (迈克·伊文斯)", "info": "地球三体组织统帅,审判日号船长"},
|
|
60
|
+
"rey-diaz": {
|
|
61
|
+
"full_name": "Manuel Rey Diaz (曼努尔·雷迪亚兹)",
|
|
62
|
+
"info": "面壁者,前委内瑞拉总统,恒星型氢弹计划",
|
|
63
|
+
},
|
|
64
|
+
"tomoko": {"full_name": "智子 (Tomoko/Sophon)", "info": "三体人用质子展开制造的超级计算机,也是人形使者"},
|
|
65
|
+
"singer": {"full_name": "歌者 (Singer)", "info": "歌者文明的清理员,向太阳系发射二向箔"},
|
|
66
|
+
},
|
|
67
|
+
"titan": {
|
|
68
|
+
"wang-xingxing": {"full_name": "王兴兴 (Wang Xingxing)", "info": "宇树科技创始人,人形机器人先驱"},
|
|
69
|
+
"ren-zhengfei": {"full_name": "任正非 (Ren Zhengfei)", "info": "华为创始人"},
|
|
70
|
+
"zhang-yiming": {"full_name": "张一鸣 (Zhang Yiming)", "info": "字节跳动创始人"},
|
|
71
|
+
"lei-jun": {"full_name": "雷军 (Lei Jun)", "info": "小米创始人"},
|
|
72
|
+
"li-yanhong": {"full_name": "李彦宏 (Li Yanhong)", "info": "百度创始人兼CEO"},
|
|
73
|
+
"li-kaifu": {"full_name": "李开复 (Kai-Fu Lee)", "info": "零一万物创始人,创新工场董事长"},
|
|
74
|
+
"liang-wenfeng": {"full_name": "梁文锋 (Liang Wenfeng)", "info": "DeepSeek / 幻方量化创始人"},
|
|
75
|
+
"yang-zhilin": {"full_name": "杨植麟 (Yang Zhilin)", "info": "月之暗面 (Moonshot AI / Kimi) 创始人"},
|
|
76
|
+
"kaiming-he": {"full_name": "何恺明 (Kaiming He)", "info": "ResNet 作者,MIT 教授,前 FAIR 研究员"},
|
|
77
|
+
"ma-huateng": {"full_name": "马化腾 (Ma Huateng)", "info": "腾讯创始人兼CEO"},
|
|
78
|
+
"wang-jian": {"full_name": "王坚 (Wang Jian)", "info": "阿里云创始人,中国工程院院士"},
|
|
79
|
+
"lu-qi": {"full_name": "陆奇 (Lu Qi)", "info": "奇绩创坛创始人,前百度COO、微软副总裁"},
|
|
80
|
+
"goodfellow": {"full_name": "Ian Goodfellow", "info": "GAN 发明者"},
|
|
81
|
+
"schmidhuber": {"full_name": "Jürgen Schmidhuber", "info": "LSTM 共同发明者,IDSIA 科学主任"},
|
|
82
|
+
"dean": {"full_name": "Jeff Dean", "info": "Google Chief Scientist,MapReduce/TensorFlow 作者"},
|
|
83
|
+
"chollet": {"full_name": "François Chollet", "info": "Keras 作者,ARC 基准提出者"},
|
|
84
|
+
"silver": {"full_name": "David Silver", "info": "AlphaGo 首席研究员,DeepMind"},
|
|
85
|
+
"carmack": {"full_name": "John Carmack", "info": "Doom/Quake 之父,前 Oculus CTO"},
|
|
86
|
+
"torvalds": {"full_name": "Linus Torvalds", "info": "Linux & Git 创始人"},
|
|
87
|
+
"wolfram": {"full_name": "Stephen Wolfram", "info": "Mathematica 创始人,Wolfram Research CEO"},
|
|
88
|
+
},
|
|
89
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
ALTER TABLE sessions ADD COLUMN last_heartbeat TEXT DEFAULT NULL;
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
-- Track delivered notifications to prevent duplicates.
|
|
2
|
+
CREATE TABLE IF NOT EXISTS notification_log(
|
|
3
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
4
|
+
notif_type TEXT NOT NULL,
|
|
5
|
+
recipient TEXT NOT NULL,
|
|
6
|
+
ref_type TEXT NOT NULL,
|
|
7
|
+
ref_id TEXT NOT NULL,
|
|
8
|
+
channel TEXT NOT NULL,
|
|
9
|
+
sent_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%d %H:%M:%S','now','localtime'))
|
|
10
|
+
);
|
|
11
|
+
CREATE INDEX IF NOT EXISTS idx_notif_dedup ON notification_log(notif_type, recipient, ref_id);
|
|
12
|
+
CREATE INDEX IF NOT EXISTS idx_notif_ts ON notification_log(sent_at);
|