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.
package/lib/monitor.py CHANGED
@@ -231,24 +231,7 @@ def handle_change(file_path: str, env: ClaudesEnv) -> None:
231
231
  pass
232
232
 
233
233
  if unread > 0:
234
- sess = f"{env.prefix}-{name}"
235
- try:
236
- r = subprocess.run(
237
- ["tmux", "has-session", "-t", sess],
238
- capture_output=True,
239
- timeout=5,
240
- )
241
- if r.returncode == 0:
242
- log(f"EVENT: {name} has {unread} unread -- nudging")
243
- subprocess.run(
244
- ["tmux", "send-keys", "-t", sess, "-l", f"./board --as {name} inbox"],
245
- timeout=5,
246
- )
247
- subprocess.run(["tmux", "send-keys", "-t", sess, "Enter"], timeout=5)
248
- else:
249
- log(f"EVENT: {name} has {unread} unread -- session not running")
250
- except Exception:
251
- pass
234
+ log(f"EVENT: {name} has {unread} unread")
252
235
 
253
236
 
254
237
  # ---------------------------------------------------------------------------
package/lib/swarm.py ADDED
@@ -0,0 +1,456 @@
1
+ """swarm — launch and manage multi-agent sessions."""
2
+
3
+ import os
4
+ import re
5
+ import threading
6
+ from dataclasses import dataclass
7
+ from datetime import datetime
8
+ from pathlib import Path
9
+
10
+ from lib.board_db import BoardDB
11
+ from lib.common import ClaudesEnv, is_suspended
12
+ from lib.swarm_backend import SessionBackend, TmuxBackend, detect_backend
13
+
14
+
15
+ @dataclass
16
+ class SwarmConfig:
17
+ env: ClaudesEnv
18
+ agent: str
19
+ backend: SessionBackend
20
+ install_home: Path
21
+
22
+ @classmethod
23
+ def load(cls) -> "SwarmConfig":
24
+ env = ClaudesEnv.load()
25
+ agent = os.environ.get("SWARM_AGENT", "claude")
26
+ backend = detect_backend()
27
+ install_home_raw = os.environ.get("CLAUDES_HOME", "")
28
+ install_home = Path(install_home_raw) if install_home_raw else env.install_home
29
+ return cls(env=env, agent=agent, backend=backend, install_home=install_home)
30
+
31
+
32
+ class SwarmManager:
33
+ def __init__(self, cfg: SwarmConfig) -> None:
34
+ self.cfg = cfg
35
+ self._pending_threads: list[threading.Thread] = []
36
+
37
+ @property
38
+ def _env(self) -> ClaudesEnv:
39
+ return self.cfg.env
40
+
41
+ def _board_path(self) -> str:
42
+ return str(self.cfg.install_home / "bin" / "board")
43
+
44
+ def build_system_prompt(self, name: str) -> str:
45
+ board = self._board_path()
46
+ return (
47
+ f"你是 {name},cnb 团队的一员。你在后台工作,通过消息板与组长和同学协作。\n"
48
+ f"协作命令:\n"
49
+ f" {board} --as {name} inbox # 查看收件箱\n"
50
+ f" {board} --as {name} ack # 清空收件箱\n"
51
+ f' {board} --as {name} send <to> "msg" # 发消息\n'
52
+ f' {board} --as {name} status "desc" # 更新状态\n'
53
+ f" {board} --as {name} task done # 完成当前任务\n"
54
+ f"规则:启动时先 inbox,完成任务后再 inbox,有进展随时汇报给发任务的人。\n"
55
+ f"你可以直接 send 给任何同学协作,不用什么都通过一个人转。"
56
+ )
57
+
58
+ def build_agent_cmd(self, name: str) -> str:
59
+ prompt = self.build_system_prompt(name)
60
+ escaped = prompt.replace("'", "'\\''")
61
+ if self.cfg.agent == "claude":
62
+ return f"claude --name '{name}' --dangerously-skip-permissions --append-system-prompt '{escaped}'"
63
+ elif self.cfg.agent == "trae":
64
+ return "trae-cli"
65
+ elif self.cfg.agent == "qwen":
66
+ return "qwen"
67
+ else:
68
+ print(f"ERROR: unknown agent: {self.cfg.agent}")
69
+ raise SystemExit(1)
70
+
71
+ def _needs_prompt_injection(self) -> bool:
72
+ return self.cfg.agent in ("trae", "qwen")
73
+
74
+ def build_initial_prompt(self, name: str) -> str:
75
+ engine_labels = {"trae": "Trae CLI", "qwen": "Qwen Code"}
76
+ engine_label = engine_labels.get(self.cfg.agent, self.cfg.agent)
77
+ sd = self._env.sessions_dir
78
+ cv = self._env.cv_dir
79
+ board = self._board_path()
80
+ return (
81
+ f"你是 {name}。你正在使用 {engine_label} 引擎。"
82
+ f"按 CLAUDE.md 的启动流程执行:"
83
+ f"1. 读取 {sd}/{name}.md,"
84
+ f"2. 读取 {cv}/{name}.md(如果存在),"
85
+ f"3. 用 '{board}' --as {name} inbox 检查未读消息,"
86
+ f"4. 根据 session 文件中的下一步继续工作,"
87
+ f"如果没有明确任务,读 ROADMAP.md 自主找活干。"
88
+ )
89
+
90
+ def _save_cmd(self, name: str) -> str:
91
+ board = self._board_path()
92
+ return (
93
+ f"git add -A && git commit -m '[WIP][{name}] auto-save before shutdown' "
94
+ f"--allow-empty-message 2>/dev/null; "
95
+ f"'{board}' --as {name} status 'shutdown: state saved'"
96
+ )
97
+
98
+ # --- Session registration ---
99
+
100
+ def ensure_registered(self, names: list[str]) -> None:
101
+ db_path = self._env.board_db
102
+ if not db_path.exists():
103
+ return
104
+ db = BoardDB(db_path)
105
+ with db.conn() as c:
106
+ for n in names:
107
+ db.ensure_session(n, c=c)
108
+
109
+ for n in names:
110
+ md = self._env.sessions_dir / f"{n}.md"
111
+ if not md.exists():
112
+ md.write_text(f"# {n}\n\n## Current task\n(none)\n\n## @inbox\n")
113
+
114
+ config_path = self._env.claudes_dir / "config.toml"
115
+ if config_path.exists():
116
+ text = config_path.read_text()
117
+ changed = False
118
+ 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'
122
+ changed = True
123
+ if changed:
124
+ config_path.write_text(text)
125
+ self._env.sessions.extend([n for n in names if n not in self._env.sessions])
126
+
127
+ # --- Attendance ---
128
+
129
+ def clock_in(self, name: str) -> None:
130
+ t = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
131
+ with open(self._env.attendance_log, "a") as f:
132
+ f.write(f"{t} | {name} | clock-in\n")
133
+
134
+ def clock_out(self, name: str) -> None:
135
+ t = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
136
+ with open(self._env.attendance_log, "a") as f:
137
+ f.write(f"{t} | {name} | clock-out\n")
138
+
139
+ def attendance(self) -> None:
140
+ log = self._env.attendance_log
141
+ if not log.exists():
142
+ print("No attendance records yet.")
143
+ return
144
+ today = datetime.now().strftime("%Y-%m-%d")
145
+ lines = log.read_text().splitlines()
146
+ print(f"=== 出勤记录 (今日: {today}) ===")
147
+ print()
148
+ for name in self._env.sessions:
149
+ today_ins = [l for l in lines if f"| {name} | clock-in" in l and l.startswith(today)]
150
+ today_outs = [l for l in lines if f"| {name} | clock-out" in l and l.startswith(today)]
151
+ last_in = today_ins[-1].split("|")[0].strip() if today_ins else ""
152
+ last_out = today_outs[-1].split("|")[0].strip() if today_outs else ""
153
+ if last_in and not last_out:
154
+ print(f" {name}: 在岗 (上班 {last_in})")
155
+ elif last_out:
156
+ print(f" {name}: 已下班 (最后 {last_out})")
157
+ else:
158
+ print(f" {name}: 今日未上班")
159
+ print()
160
+ print(f"历史记录: {log}")
161
+
162
+ # --- Logging ---
163
+
164
+ def log_startup(self, name: str) -> None:
165
+ t = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
166
+ msg = f"[{t}] Starting {name} with agent: {self.cfg.agent}\n"
167
+ log_dir = self._env.log_dir
168
+ log_dir.mkdir(parents=True, exist_ok=True)
169
+ with open(log_dir / "swarm.log", "a") as f:
170
+ f.write(msg)
171
+ with open(log_dir / f"{name}.log", "a") as f:
172
+ f.write(msg)
173
+
174
+ # --- Role filtering ---
175
+
176
+ def get_role(self, name: str) -> str:
177
+ roster = self._env.claudes_dir / "ROSTER.md"
178
+ if not roster.exists():
179
+ return "unknown"
180
+ try:
181
+ text = roster.read_text()
182
+ except OSError:
183
+ 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"
195
+
196
+ def filter_sessions(self, *, role: str = "", exclude: str = "") -> list[str]:
197
+ result: list[str] = []
198
+ for name in self._env.sessions:
199
+ r = self.get_role(name)
200
+ if exclude and r == exclude:
201
+ continue
202
+ if role and r != role:
203
+ continue
204
+ result.append(name)
205
+ return result
206
+
207
+ # --- High-level commands ---
208
+
209
+ def _start_one(self, name: str) -> None:
210
+ prefix = self._env.prefix
211
+ backend = self.cfg.backend
212
+
213
+ if backend.is_running(prefix, name):
214
+ print(f" {name}: already running")
215
+ return
216
+
217
+ self.log_startup(name)
218
+ agent_cmd = self.build_agent_cmd(name)
219
+ backend.start_session(prefix, name, self._env.project_root, agent_cmd)
220
+
221
+ if isinstance(backend, TmuxBackend):
222
+ t_trust = threading.Thread(target=backend.auto_accept_trust, args=(prefix, name))
223
+ t_trust.start()
224
+ self._pending_threads.append(t_trust)
225
+
226
+ if self._needs_prompt_injection() or isinstance(backend, TmuxBackend):
227
+ initial = self.build_initial_prompt(name)
228
+ t = threading.Thread(
229
+ target=backend.inject_initial_prompt,
230
+ args=(prefix, name, initial, self._env.log_dir),
231
+ )
232
+ t.start()
233
+ self._pending_threads.append(t)
234
+
235
+ print(
236
+ f" {name}: started ({type(backend).__name__.lower().replace('backend', '')}: {prefix}-{name}, agent: {self.cfg.agent})"
237
+ )
238
+
239
+ def start(self, names: list[str], *, dry_run: bool = False, role: str = "", exclude: str = "") -> None:
240
+ if not names:
241
+ if role or exclude:
242
+ targets = self.filter_sessions(role=role, exclude=exclude)
243
+ else:
244
+ targets = list(self._env.sessions)
245
+ else:
246
+ targets = names
247
+
248
+ if dry_run:
249
+ print(f"=== DRY RUN: would start {len(targets)} session(s) ===")
250
+ for name in targets:
251
+ sf = self._env.suspended_file
252
+ if is_suspended(name, sf):
253
+ print(f" {name}: SUSPENDED (would skip)")
254
+ elif self.cfg.backend.is_running(self._env.prefix, name):
255
+ print(f" {name}: already running (would skip)")
256
+ else:
257
+ backend_name = type(self.cfg.backend).__name__.lower().replace("backend", "")
258
+ print(f" {name}: would start (mode: {backend_name}, agent: {self.cfg.agent})")
259
+ return
260
+
261
+ self.ensure_registered(targets)
262
+
263
+ if isinstance(self.cfg.backend, TmuxBackend):
264
+ self.cfg.backend.enable_mouse()
265
+
266
+ started = 0
267
+ sf = self._env.suspended_file
268
+ for name in targets:
269
+ if is_suspended(name, sf):
270
+ print(f" {name}: SUSPENDED (use 'swarm resume {name}' to reactivate)")
271
+ continue
272
+ self._start_one(name)
273
+ self.clock_in(name)
274
+ started += 1
275
+
276
+ for t in self._pending_threads:
277
+ t.join(timeout=90)
278
+ self._pending_threads.clear()
279
+
280
+ print()
281
+ backend_name = type(self.cfg.backend).__name__.lower().replace("backend", "")
282
+ print(f"Mode: {backend_name} | Agent: {self.cfg.agent} | Started: {started}")
283
+ print(f"Logs: {self._env.log_dir}")
284
+ if isinstance(self.cfg.backend, TmuxBackend):
285
+ print(f" tmux attach -t {self._env.prefix}-<name> # attach (Ctrl-B D to detach)")
286
+ else:
287
+ print(f" screen -r {self._env.prefix}-<name> # attach (Ctrl-A D to detach)")
288
+ print(" swarm status # who's running")
289
+
290
+ def status(self) -> None:
291
+ backend_name = type(self.cfg.backend).__name__.lower().replace("backend", "")
292
+ print(f"=== Swarm Status (mode: {backend_name}, agent: {self.cfg.agent}) ===")
293
+ prefix = self._env.prefix
294
+ sf = self._env.suspended_file
295
+ for name in self._env.sessions:
296
+ if is_suspended(name, sf):
297
+ print(f" {name}: SUSPENDED")
298
+ elif self.cfg.backend.is_running(prefix, name):
299
+ line = self.cfg.backend.status_line(prefix, name, self.cfg.agent)
300
+ print(f" {name}: {line}")
301
+ else:
302
+ print(f" {name}: stopped")
303
+
304
+ def stop(self, names: list[str], *, dry_run: bool = False, force: bool = False) -> None:
305
+ if not names:
306
+ targets = list(self._env.sessions)
307
+ else:
308
+ targets = names
309
+
310
+ if dry_run:
311
+ print(f"=== DRY RUN: would stop {len(targets)} session(s) ===")
312
+ for name in targets:
313
+ if self.cfg.backend.is_running(self._env.prefix, name):
314
+ print(f" {name}: would stop (running)")
315
+ else:
316
+ print(f" {name}: not running (would skip)")
317
+ return
318
+
319
+ if not names and not force:
320
+ running_count = sum(1 for n in targets if self.cfg.backend.is_running(self._env.prefix, n))
321
+ if running_count > 0:
322
+ print(f"About to stop ALL sessions ({running_count} running).")
323
+ try:
324
+ answer = input("Continue? [y/N] ").strip().lower()
325
+ except (EOFError, KeyboardInterrupt):
326
+ print("\nCancelled.")
327
+ return
328
+ if answer not in ("y", "yes"):
329
+ print("Cancelled.")
330
+ return
331
+
332
+ prefix = self._env.prefix
333
+ for name in targets:
334
+ if not self.cfg.backend.is_running(prefix, name):
335
+ print(f" {name}: not running")
336
+ continue
337
+ self.cfg.backend.stop_session(prefix, name, self._save_cmd(name))
338
+ self.clock_out(name)
339
+
340
+ if not names:
341
+ self._kill_dispatcher_pids()
342
+
343
+ def _kill_dispatcher_pids(self) -> None:
344
+ for pidname in ("dispatcher.pid", "dispatcher-watchdog.pid"):
345
+ pidfile = self._env.claudes_dir / pidname
346
+ if pidfile.exists():
347
+ try:
348
+ pid = int(pidfile.read_text().strip())
349
+ os.kill(pid, 0)
350
+ os.kill(pid, 15)
351
+ except (ValueError, ProcessLookupError, PermissionError):
352
+ pass
353
+ pidfile.unlink(missing_ok=True)
354
+
355
+ def restart(self, names: list[str]) -> None:
356
+ sf = self._env.suspended_file
357
+ if not names:
358
+ targets = [n for n in self._env.sessions if not is_suspended(n, sf)]
359
+ else:
360
+ targets = names
361
+
362
+ print(f"=== Restarting {len(targets)} session(s) ===")
363
+ prefix = self._env.prefix
364
+ for name in targets:
365
+ if is_suspended(name, sf):
366
+ print(f" {name}: SUSPENDED (use 'swarm resume {name}' to reactivate)")
367
+ continue
368
+ if self.cfg.backend.is_running(prefix, name):
369
+ self.cfg.backend.stop_session(prefix, name, self._save_cmd(name))
370
+ import time
371
+
372
+ time.sleep(1)
373
+ self._start_one(name)
374
+ print(f" {name}: restarted")
375
+
376
+ def suspend(self, names: list[str]) -> None:
377
+ if not names:
378
+ print("Usage: swarm suspend <name> [names...]")
379
+ raise SystemExit(1)
380
+ sf = self._env.suspended_file
381
+ prefix = self._env.prefix
382
+ for name in names:
383
+ if name not in self._env.sessions:
384
+ print(f" {name}: unknown session (valid: {' '.join(self._env.sessions)})")
385
+ continue
386
+ if is_suspended(name, sf):
387
+ print(f" {name}: already suspended")
388
+ continue
389
+ with open(sf, "a") as f:
390
+ f.write(name + "\n")
391
+ if self.cfg.backend.is_running(prefix, name):
392
+ self.cfg.backend.stop_session(prefix, name, self._save_cmd(name))
393
+ self.clock_out(name)
394
+ print(f" {name}: suspended")
395
+
396
+ def resume(self, names: list[str]) -> None:
397
+ if not names:
398
+ print("Usage: swarm resume <name> [names...]")
399
+ raise SystemExit(1)
400
+ sf = self._env.suspended_file
401
+ prefix = self._env.prefix
402
+ for name in names:
403
+ if name not in self._env.sessions:
404
+ print(f" {name}: unknown session (valid: {' '.join(self._env.sessions)})")
405
+ continue
406
+ if not is_suspended(name, sf):
407
+ print(f" {name}: not suspended")
408
+ continue
409
+ if sf.exists():
410
+ lines = [l for l in sf.read_text().splitlines() if l != name]
411
+ sf.write_text("\n".join(lines) + "\n" if lines else "")
412
+ self._start_one(name)
413
+ import time
414
+
415
+ time.sleep(1)
416
+ if self.cfg.backend.is_running(prefix, name):
417
+ self.clock_in(name)
418
+ print(f" {name}: resumed + started")
419
+ else:
420
+ print(f" {name}: resumed but FAILED to start (check tmux/claude availability)")
421
+
422
+ def attach(self, name: str) -> None:
423
+ if not name:
424
+ print("Usage: swarm attach <name>")
425
+ raise SystemExit(1)
426
+ self.cfg.backend.attach(self._env.prefix, name)
427
+
428
+ def help(self) -> None:
429
+ backend_name = type(self.cfg.backend).__name__.lower().replace("backend", "")
430
+ print(f"""\
431
+ swarm — manage multi-agent session swarm
432
+
433
+ Mode: {backend_name} (override with SWARM_MODE=tmux|screen)
434
+ Agent: {self.cfg.agent} (override with SWARM_AGENT=claude|trae|qwen)
435
+
436
+ start [names...] Launch sessions (default: all non-suspended)
437
+ start --role=dev Launch only sessions with matching role
438
+ start --exclude=intern Launch all except matching role
439
+ status Who's running
440
+ stop [names...] Gracefully stop sessions (default: all)
441
+ restart [names...] Stop + start (default: all non-suspended)
442
+ suspend <names...> Suspend sessions (stop + skip on future starts)
443
+ resume <names...> Resume suspended sessions (remove from list + start)
444
+ attach <name> Attach to session
445
+ attendance Show attendance records
446
+ help This message
447
+
448
+ Roles (from ROSTER.md): lead, dev, intern, dispatcher
449
+
450
+ Examples:
451
+ swarm start # launch all with Claude (default)
452
+ SWARM_AGENT=trae swarm start # launch all with Trae
453
+ swarm start alice bob # launch specific sessions
454
+ swarm attach alice # interactive access
455
+ swarm stop bob # stop one
456
+ SWARM_MODE=screen swarm start # force screen mode""")