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.
Files changed (49) hide show
  1. package/Makefile +8 -2
  2. package/README.md +40 -56
  3. package/VERSION +1 -1
  4. package/bin/board +102 -34
  5. package/bin/cnb +59 -33
  6. package/bin/dispatcher +25 -11
  7. package/bin/doctor +3 -5
  8. package/bin/init +8 -8
  9. package/bin/notify +224 -0
  10. package/bin/registry +8 -23
  11. package/bin/sync-version +131 -0
  12. package/lib/board_admin.py +19 -9
  13. package/lib/board_bbs.py +23 -8
  14. package/lib/board_bug.py +2 -1
  15. package/lib/board_db.py +18 -6
  16. package/lib/board_lock.py +5 -0
  17. package/lib/board_mailbox.py +6 -4
  18. package/lib/board_msg.py +112 -24
  19. package/lib/board_pending.py +233 -0
  20. package/lib/board_pulse.py +14 -0
  21. package/lib/board_task.py +22 -10
  22. package/lib/board_tui.py +28 -20
  23. package/lib/board_view.py +60 -28
  24. package/lib/board_vote.py +9 -3
  25. package/lib/build_lock.py +7 -7
  26. package/lib/common.py +45 -3
  27. package/lib/concerns/__init__.py +4 -1
  28. package/lib/concerns/coral.py +1 -1
  29. package/lib/concerns/digest_scheduler.py +109 -0
  30. package/lib/concerns/file_watcher.py +73 -68
  31. package/lib/concerns/health.py +1 -1
  32. package/lib/concerns/notification_push.py +171 -0
  33. package/lib/concerns/notifications.py +58 -3
  34. package/lib/concerns/nudge_coordinator.py +148 -0
  35. package/lib/digest.py +62 -0
  36. package/lib/health.py +2 -2
  37. package/lib/inject.py +2 -2
  38. package/lib/monitor.py +8 -4
  39. package/lib/notification_config.py +101 -0
  40. package/lib/swarm.py +43 -35
  41. package/lib/swarm_backend.py +63 -29
  42. package/lib/theme_profiles.py +89 -0
  43. package/migrations/004_heartbeat.sql +1 -0
  44. package/migrations/005_notification_log.sql +12 -0
  45. package/migrations/006_pending_actions.sql +15 -0
  46. package/package.json +4 -3
  47. package/pyproject.toml +3 -2
  48. package/registry/README.md +9 -0
  49. 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
- pass
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
- sel.register(self.proc.stdout, selectors.EVENT_READ)
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
- sys.exit(1)
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"你是 {name},cnb 团队的一员。你在后台工作,通过消息板与组长和同学协作。\n"
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
- text = config_path.read_text()
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 f'"{n}"' not in text:
120
- text = text.replace("sessions = [", f'sessions = ["{n}", ', 1)
121
- text += f'\n[session.{n}]\npersona = ""\n'
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
- config_path.write_text(text)
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
- roster = self._env.claudes_dir / "ROSTER.md"
178
- if not roster.exists():
193
+ config_path = self._env.claudes_dir / "config.toml"
194
+ if not config_path.exists():
179
195
  return "unknown"
180
196
  try:
181
- text = roster.read_text()
182
- except OSError:
197
+ data = tomllib.loads(config_path.read_text())
198
+ except (OSError, tomllib.TOMLDecodeError):
183
199
  return "unknown"
184
- for line in text.splitlines():
185
- if re.search(rf"\| \*\*{re.escape(name)}\*\*", line, re.IGNORECASE):
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
- print(f" {name}: already running")
215
- return
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} | Agent: {self.cfg.agent} | Started: {started}")
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"=== Swarm Status (mode: {backend_name}, agent: {self.cfg.agent}) ===")
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.is_running(prefix, name):
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 — manage multi-agent session swarm
439
+ swarm — 管理同学协作会话
432
440
 
433
441
  Mode: {backend_name} (override with SWARM_MODE=tmux|screen)
434
- Agent: {self.cfg.agent} (override with SWARM_AGENT=claude|trae|qwen)
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 ROSTER.md): lead, dev, intern, dispatcher
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)
@@ -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, agent: {agent})"
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(["screen", "-S", sess, "-p", "0", "-X", "stuff", f"cd '{project_root}'"])
197
- subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", "\r"])
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, agent: {agent}) {state}"
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);