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/bin/swarm CHANGED
@@ -1,895 +1,76 @@
1
1
  #!/usr/bin/env python3
2
- """swarm — Launch and manage multi-agent sessions in tmux or screen.
2
+ """swarm — Launch and manage multi-agent sessions.
3
3
 
4
- Auto-detects: prefers tmux (truecolor support), falls back to screen.
5
- Override with SWARM_MODE=tmux|screen or SWARM_AGENT=claude|trae|qwen.
4
+ Thin CLI wrapper; core logic lives in lib/swarm.py.
6
5
  """
7
6
 
8
- import os
9
- import re
10
- import shutil
11
- import subprocess
12
7
  import sys
13
- import threading
14
- import time
15
- from datetime import datetime
16
8
  from pathlib import Path
17
9
 
18
- # ---------------------------------------------------------------------------
19
- # Resolve CLAUDES_HOME and load shared environment
20
- # ---------------------------------------------------------------------------
21
- _self = Path(__file__).resolve()
22
- CLAUDES_HOME = _self.parent.parent
10
+ CLAUDES_HOME = Path(__file__).resolve().parent.parent
11
+ sys.path.insert(0, str(CLAUDES_HOME))
23
12
 
24
- # Add lib/ to path so we can import common
25
- sys.path.insert(0, str(CLAUDES_HOME / "lib"))
26
- from common import ClaudesEnv, is_suspended
13
+ from lib.swarm import SwarmConfig, SwarmManager
27
14
 
28
- # Module state — initialized by _load(), not at import time.
29
- PREFIX: str = ""
30
- SUSPENDED_FILE: Path = Path()
31
- LOG_DIR: Path = Path()
32
- SESSIONS: list[str] = []
33
- PROJECT_ROOT: Path = Path()
34
- CLAUDES_DIR: Path = Path()
35
- SESSIONS_DIR: Path = Path()
36
- CV_DIR: Path = Path()
37
- ATTENDANCE_LOG: Path = Path()
38
- SWARM_AGENT: str = ""
39
- DEEPSEEK_SESSIONS: str = ""
40
- ROSTER_FILE: Path = Path()
41
15
 
42
- _loaded = False
43
-
44
-
45
- def _load() -> None:
46
- global _loaded, PREFIX, SUSPENDED_FILE, LOG_DIR, SESSIONS, PROJECT_ROOT
47
- global CLAUDES_DIR, SESSIONS_DIR, CV_DIR, ATTENDANCE_LOG
48
- global SWARM_AGENT, DEEPSEEK_SESSIONS, ROSTER_FILE, MODE
49
- if _loaded:
50
- return
51
- env = ClaudesEnv.load()
52
- PREFIX = env.prefix
53
- SUSPENDED_FILE = env.suspended_file
54
- LOG_DIR = env.log_dir
55
- SESSIONS = env.sessions
56
- PROJECT_ROOT = env.project_root
57
- CLAUDES_DIR = env.claudes_dir
58
- SESSIONS_DIR = env.sessions_dir
59
- CV_DIR = env.cv_dir
60
- ATTENDANCE_LOG = env.attendance_log
61
- SWARM_AGENT = os.environ.get("SWARM_AGENT", "claude")
62
- DEEPSEEK_SESSIONS = ""
63
- ROSTER_FILE = CLAUDES_DIR / "ROSTER.md"
64
- MODE = detect_mode()
65
- LOG_DIR.mkdir(parents=True, exist_ok=True)
66
- _loaded = True
67
-
68
-
69
- # ---------------------------------------------------------------------------
70
- # Mode detection
71
- # ---------------------------------------------------------------------------
72
-
73
-
74
- def detect_mode() -> str:
75
- override = os.environ.get("SWARM_MODE", "")
76
- if override:
77
- return override
78
- if shutil.which("tmux"):
79
- return "tmux"
80
- if shutil.which("screen"):
81
- return "screen"
82
- return "none"
83
-
84
-
85
- MODE: str = ""
86
-
87
-
88
- # ---------------------------------------------------------------------------
89
- # Attendance (打卡)
90
- # ---------------------------------------------------------------------------
91
-
92
-
93
- def clock_in(name: str) -> None:
94
- ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
95
- with open(ATTENDANCE_LOG, "a") as f:
96
- f.write(f"{ts} | {name} | clock-in\n")
97
-
98
-
99
- def clock_out(name: str) -> None:
100
- ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
101
- with open(ATTENDANCE_LOG, "a") as f:
102
- f.write(f"{ts} | {name} | clock-out\n")
103
-
104
-
105
- def do_attendance() -> None:
106
- if not ATTENDANCE_LOG.exists():
107
- print("No attendance records yet.")
108
- return
109
- today = datetime.now().strftime("%Y-%m-%d")
110
- lines = ATTENDANCE_LOG.read_text().splitlines()
111
- print(f"=== 出勤记录 (今日: {today}) ===")
112
- print()
113
- for name in SESSIONS:
114
- today_ins = [l for l in lines if f"| {name} | clock-in" in l and l.startswith(today)]
115
- today_outs = [l for l in lines if f"| {name} | clock-out" in l and l.startswith(today)]
116
- last_in = today_ins[-1].split("|")[0].strip() if today_ins else ""
117
- last_out = today_outs[-1].split("|")[0].strip() if today_outs else ""
118
- if last_in and not last_out:
119
- print(f" {name}: 在岗 (上班 {last_in})")
120
- elif last_out:
121
- print(f" {name}: 已下班 (最后 {last_out})")
122
- else:
123
- print(f" {name}: 今日未上班")
124
- print()
125
- print(f"历史记录: {ATTENDANCE_LOG}")
126
-
127
-
128
- # ---------------------------------------------------------------------------
129
- # Logging
130
- # ---------------------------------------------------------------------------
131
-
132
-
133
- def log_startup(name: str) -> None:
134
- ts = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
135
- msg = f"[{ts}] Starting {name} with agent: {SWARM_AGENT}\n"
136
- with open(LOG_DIR / "swarm.log", "a") as f:
137
- f.write(msg)
138
- with open(LOG_DIR / f"{name}.log", "a") as f:
139
- f.write(msg)
140
-
141
-
142
- # ---------------------------------------------------------------------------
143
- # Agent command builder
144
- # ---------------------------------------------------------------------------
145
-
146
-
147
- def is_deepseek_session(name: str) -> bool:
148
- return name in DEEPSEEK_SESSIONS.split()
149
-
150
-
151
- def _worker_system_prompt(name: str) -> str:
152
- board = f"{CLAUDES_HOME}/bin/board"
153
- return (
154
- f"你是 {name},cnb 团队的一员。你在后台工作,通过消息板与组长和同学协作。\n"
155
- f"协作命令:\n"
156
- f" {board} --as {name} inbox # 查看收件箱\n"
157
- f" {board} --as {name} ack # 清空收件箱\n"
158
- f' {board} --as {name} send <to> "msg" # 发消息\n'
159
- f' {board} --as {name} status "desc" # 更新状态\n'
160
- f" {board} --as {name} task done # 完成当前任务\n"
161
- f"规则:启动时先 inbox,完成任务后再 inbox,有进展随时汇报给发任务的人。\n"
162
- f"你可以直接 send 给任何同学协作,不用什么都通过一个人转。"
163
- )
164
-
165
-
166
- def build_agent_cmd(name: str) -> str:
167
- env_prefix = ""
168
- if is_deepseek_session(name):
169
- env_prefix = "ANTHROPIC_BASE_URL=https://api.deepseek.com/anthropic ANTHROPIC_API_KEY=$DEEPSEEK_API_KEY "
170
- prompt = _worker_system_prompt(name)
171
- escaped_prompt = prompt.replace("'", "'\\''")
172
- if SWARM_AGENT == "claude":
173
- if is_deepseek_session(name):
174
- return (
175
- f"{env_prefix}claude --model deepseek-v4-pro[1m] --name '{name}' "
176
- f"--append-system-prompt '{escaped_prompt}'"
177
- )
178
- else:
179
- return f"claude --name '{name}' --dangerously-skip-permissions --append-system-prompt '{escaped_prompt}'"
180
- elif SWARM_AGENT == "trae":
181
- return "trae-cli"
182
- elif SWARM_AGENT == "qwen":
183
- return "qwen"
184
- else:
185
- print(f"Unknown agent: {SWARM_AGENT}", file=sys.stderr)
186
- sys.exit(1)
187
-
188
-
189
- def needs_prompt_injection() -> bool:
190
- return SWARM_AGENT in ("trae", "qwen")
191
-
192
-
193
- def get_initial_prompt(name: str) -> str:
194
- engine_labels = {"trae": "Trae CLI", "qwen": "Qwen Code"}
195
- engine_label = engine_labels.get(SWARM_AGENT, SWARM_AGENT)
196
- return (
197
- f"你是 {name}。你正在使用 {engine_label} 引擎。"
198
- f"按 CLAUDE.md 的启动流程执行:"
199
- f"1. 读取 {SESSIONS_DIR}/{name}.md,"
200
- f"2. 读取 {CV_DIR}/{name}.md(如果存在),"
201
- f"3. 用 '{CLAUDES_HOME}/bin/board' --as {name} inbox 检查未读消息,"
202
- f"4. 根据 session 文件中的下一步继续工作,"
203
- f"如果没有明确任务,读 ROADMAP.md 自主找活干。"
204
- )
205
-
206
-
207
- # ---------------------------------------------------------------------------
208
- # Role filtering
209
- # ---------------------------------------------------------------------------
210
-
211
-
212
- def get_role(name: str) -> str:
213
- if not ROSTER_FILE.exists():
214
- return "unknown"
215
- try:
216
- text = ROSTER_FILE.read_text()
217
- except OSError:
218
- return "unknown"
219
- for line in text.splitlines():
220
- if re.search(rf"\| \*\*{re.escape(name)}\*\*", line, re.IGNORECASE):
221
- lower = line.lower()
222
- if "实习生" in lower:
223
- return "intern"
224
- if "调度员" in lower:
225
- return "dispatcher"
226
- if "lead" in lower:
227
- return "lead"
228
- return "dev"
229
- return "unknown"
230
-
231
-
232
- def filter_by_role(role_filter: str, exclude_filter: str) -> list[str]:
233
- result: list[str] = []
234
- for name in SESSIONS:
235
- role = get_role(name)
236
- if exclude_filter and role == exclude_filter:
237
- continue
238
- if role_filter and role != role_filter:
239
- continue
240
- result.append(name)
241
- return result
242
-
243
-
244
- def is_valid_session(name: str) -> bool:
245
- return name in SESSIONS
246
-
247
-
248
- # ---------------------------------------------------------------------------
249
- # tmux backend
250
- # ---------------------------------------------------------------------------
251
-
252
-
253
- def tmux_is_running(name: str) -> bool:
254
- sess = f"{PREFIX}-{name}"
255
- r = subprocess.run(
256
- ["tmux", "has-session", "-t", sess],
257
- capture_output=True,
258
- )
259
- return r.returncode == 0
260
-
261
-
262
- def _inject_initial_prompt(name: str, sess: str) -> None:
263
- """Wait for Claude Code prompt to appear, then inject startup message."""
264
- initial_prompt = get_initial_prompt(name)
265
- waited = 0
266
- while waited < 60:
267
- r = subprocess.run(
268
- ["tmux", "capture-pane", "-t", sess, "-p"],
269
- capture_output=True,
270
- text=True,
271
- )
272
- pane_text = r.stdout if r.returncode == 0 else ""
273
- if "❯" in pane_text:
274
- time.sleep(1) # small buffer after prompt appears
275
- subprocess.run(["tmux", "send-keys", "-t", sess, "-l", initial_prompt])
276
- subprocess.run(["tmux", "send-keys", "-t", sess, "Enter"])
277
- return
278
- time.sleep(2)
279
- waited += 2
280
- # Timeout — log warning
281
- with open(LOG_DIR / f"{name}.log", "a") as f:
282
- f.write(f"[WARN] {name}: prompt not detected after 60s, skipping injection\n")
283
-
284
-
285
- def _auto_accept_trust(name: str, sess: str) -> None:
286
- waited = 0
287
- while waited < 60:
288
- r = subprocess.run(["tmux", "capture-pane", "-t", sess, "-p"], capture_output=True, text=True)
289
- if "I trust" in r.stdout or "trust this folder" in r.stdout.lower():
290
- time.sleep(0.5)
291
- subprocess.run(["tmux", "send-keys", "-t", sess, "Enter"])
292
- return
293
- time.sleep(2)
294
- waited += 2
295
-
296
-
297
- _pending_threads: list[threading.Thread] = []
298
-
299
-
300
- def _wait_for_shell(sess: str, timeout: int = 15) -> bool:
301
- """Poll tmux pane until a shell prompt appears."""
302
- waited = 0
303
- while waited < timeout:
304
- r = subprocess.run(["tmux", "capture-pane", "-t", sess, "-p"], capture_output=True, text=True)
305
- if r.returncode == 0 and ("$" in r.stdout or "%" in r.stdout or "❯" in r.stdout):
306
- return True
307
- time.sleep(1)
308
- waited += 1
309
- return False
310
-
311
-
312
- def _ensure_registered(names: list[str]) -> None:
313
- """Auto-register unknown session names in DB and config.toml."""
314
- import sqlite3 as _sqlite3
315
-
316
- db_path = CLAUDES_DIR / "board.db"
317
- if not db_path.exists():
318
- return
319
- conn = _sqlite3.connect(str(db_path))
320
- conn.execute("PRAGMA journal_mode=WAL;")
321
- existing = {r[0] for r in conn.execute("SELECT name FROM sessions").fetchall()}
322
- new_names = [n for n in names if n not in existing]
323
- if not new_names:
324
- conn.close()
325
- return
326
- for n in new_names:
327
- conn.execute("INSERT OR IGNORE INTO sessions(name, persona) VALUES (?, ?)", (n, ""))
328
- md = SESSIONS_DIR / f"{n}.md"
329
- if not md.exists():
330
- md.write_text(f"# {n}\n\n## Current task\n(none)\n\n## @inbox\n")
331
- conn.commit()
332
- conn.close()
333
-
334
- config_path = CLAUDES_DIR / "config.toml"
335
- if config_path.exists():
336
- text = config_path.read_text()
337
- for n in new_names:
338
- if f'"{n}"' not in text:
339
- text = text.replace("sessions = [", f'sessions = ["{n}", ', 1)
340
- text += f'\n[session.{n}]\npersona = ""\n'
341
- config_path.write_text(text)
342
- SESSIONS.extend(new_names)
343
-
344
-
345
- def tmux_start_one(name: str) -> None:
346
- sess = f"{PREFIX}-{name}"
347
- if tmux_is_running(name):
348
- print(f" {name}: already running")
349
- return
350
-
351
- log_startup(name)
352
-
353
- subprocess.run(["tmux", "new-session", "-d", "-s", sess, "-x", "200", "-y", "50"])
354
-
355
- _wait_for_shell(sess, timeout=10)
356
- subprocess.run(
357
- [
358
- "tmux",
359
- "send-keys",
360
- "-t",
361
- sess,
362
- f"source ~/.zprofile 2>/dev/null; source ~/.zshrc 2>/dev/null; cd '{PROJECT_ROOT}'",
363
- "Enter",
364
- ]
365
- )
366
-
367
- _wait_for_shell(sess, timeout=10)
368
-
369
- if is_deepseek_session(name):
370
- subprocess.run(["tmux", "send-keys", "-t", sess, "claude /logout 2>/dev/null; true", "Enter"])
371
- time.sleep(1)
372
-
373
- agent_cmd = build_agent_cmd(name)
374
- subprocess.run(["tmux", "send-keys", "-t", sess, agent_cmd, "Enter"])
375
-
376
- t_trust = threading.Thread(target=_auto_accept_trust, args=(name, sess))
377
- t_trust.start()
378
- _pending_threads.append(t_trust)
379
-
380
- t = threading.Thread(target=_inject_initial_prompt, args=(name, sess))
381
- t.start()
382
- _pending_threads.append(t)
383
-
384
- print(f" {name}: started (tmux: {sess}, agent: {SWARM_AGENT})")
385
-
386
-
387
- def tmux_stop_one(name: str) -> None:
388
- sess = f"{PREFIX}-{name}"
389
- if not tmux_is_running(name):
390
- print(f" {name}: not running")
391
- return
392
-
393
- # Ask session to save state before exiting
394
- save_cmd = (
395
- f"git add -A && git commit -m '[WIP][{name}] auto-save before shutdown' "
396
- f"--allow-empty-message 2>/dev/null; "
397
- f"'{CLAUDES_HOME}/bin/board' --as {name} status 'shutdown: state saved'"
398
- )
399
- subprocess.run(["tmux", "send-keys", "-t", sess, "C-c"])
400
- time.sleep(1)
401
- subprocess.run(["tmux", "send-keys", "-t", sess, f"! {save_cmd}", "Enter"])
402
- time.sleep(3)
403
-
404
- subprocess.run(["tmux", "send-keys", "-t", sess, "/exit", "Enter"])
405
-
406
- # Wait up to 15s for graceful exit
407
- waited = 0
408
- while tmux_is_running(name) and waited < 15:
409
- time.sleep(1)
410
- waited += 1
411
- if tmux_is_running(name):
412
- subprocess.run(["tmux", "kill-session", "-t", sess])
413
- print(f" {name}: force killed (after {waited}s)")
414
- else:
415
- print(f" {name}: exited gracefully")
416
-
417
-
418
- def tmux_status() -> None:
419
- for name in SESSIONS:
420
- if is_suspended(name, SUSPENDED_FILE):
421
- print(f" {name}: SUSPENDED")
422
- elif tmux_is_running(name):
423
- print(f" {name}: running (tmux, agent: {SWARM_AGENT})")
424
- else:
425
- print(f" {name}: stopped")
426
-
427
-
428
- def tmux_attach(name: str) -> None:
429
- os.execvp("tmux", ["tmux", "attach-session", "-t", f"{PREFIX}-{name}"])
430
-
431
-
432
- def tmux_inject(name: str, message: str) -> None:
433
- sess = f"{PREFIX}-{name}"
434
- if not tmux_is_running(name):
435
- print(f" {name}: not running")
436
- sys.exit(1)
437
- oneline = message.replace("\n", " ")
438
- subprocess.run(["tmux", "send-keys", "-t", sess, "-l", oneline])
439
- subprocess.run(["tmux", "send-keys", "-t", sess, "Enter"])
440
- print(f" {name}: injected")
441
-
442
-
443
- # ---------------------------------------------------------------------------
444
- # screen backend
445
- # ---------------------------------------------------------------------------
446
-
447
-
448
- def screen_is_running(name: str) -> bool:
449
- sess_tag = f".{PREFIX}-{name}"
450
- r = subprocess.run(["screen", "-list"], capture_output=True, text=True)
451
- output = r.stdout + r.stderr # screen may write to stderr
452
- return bool(re.search(rf"{re.escape(sess_tag)}\s", output))
453
-
454
-
455
- def screen_start_one(name: str) -> None:
456
- sess = f"{PREFIX}-{name}"
457
- if screen_is_running(name):
458
- print(f" {name}: already running")
459
- return
460
-
461
- log_startup(name)
462
-
463
- subprocess.run(["screen", "-dmS", sess])
464
- time.sleep(1)
465
- subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", f"cd '{PROJECT_ROOT}'"])
466
- subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", "\r"])
467
- time.sleep(0.5)
468
-
469
- agent_cmd = build_agent_cmd(name)
470
- subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", agent_cmd])
471
- subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", "\r"])
472
-
473
- if needs_prompt_injection():
474
- time.sleep(3)
475
- initial_prompt = get_initial_prompt(name)
476
- subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", initial_prompt])
477
- time.sleep(0.3)
478
- subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", "\r"])
479
-
480
- print(f" {name}: started (screen: {sess}, agent: {SWARM_AGENT})")
481
-
482
-
483
- def screen_stop_one(name: str) -> None:
484
- sess = f"{PREFIX}-{name}"
485
- if not screen_is_running(name):
486
- print(f" {name}: not running")
487
- return
488
-
489
- # Ask session to save state before exiting
490
- save_cmd = (
491
- f"git add -A && git commit -m '[WIP][{name}] auto-save before shutdown' "
492
- f"--allow-empty-message 2>/dev/null; "
493
- f"'{CLAUDES_HOME}/bin/board' --as {name} status 'shutdown: state saved'"
494
- )
495
- subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", "\x03"])
496
- time.sleep(1)
497
- subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", f"! {save_cmd}\r"])
498
- time.sleep(3)
499
-
500
- subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", "/exit\r"])
501
-
502
- # Wait up to 15s for graceful exit
503
- waited = 0
504
- while screen_is_running(name) and waited < 15:
505
- time.sleep(1)
506
- waited += 1
507
- if screen_is_running(name):
508
- subprocess.run(["screen", "-S", sess, "-X", "quit"])
509
- print(f" {name}: force killed (after {waited}s)")
510
- else:
511
- print(f" {name}: exited gracefully")
512
-
513
-
514
- def screen_status() -> None:
515
- for name in SESSIONS:
516
- if is_suspended(name, SUSPENDED_FILE):
517
- print(f" {name}: SUSPENDED")
518
- elif screen_is_running(name):
519
- r = subprocess.run(["screen", "-list"], capture_output=True, text=True)
520
- output = r.stdout + r.stderr
521
- state = ""
522
- for line in output.splitlines():
523
- if f".{PREFIX}-{name}" in line:
524
- parts = line.strip().split()
525
- if parts:
526
- state = parts[-1]
527
- break
528
- print(f" {name}: running (screen, agent: {SWARM_AGENT}) {state}")
529
- else:
530
- print(f" {name}: stopped")
531
-
532
-
533
- def screen_attach(name: str) -> None:
534
- os.execvp("screen", ["screen", "-r", f"{PREFIX}-{name}"])
535
-
536
-
537
- def screen_inject(name: str, message: str) -> None:
538
- sess = f"{PREFIX}-{name}"
539
- if not screen_is_running(name):
540
- print(f" {name}: not running")
541
- sys.exit(1)
542
- oneline = message.replace("\n", " ")
543
- subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", oneline])
544
- time.sleep(0.3)
545
- subprocess.run(["screen", "-S", sess, "-p", "0", "-X", "stuff", "\r"])
546
- print(f" {name}: injected")
547
-
548
-
549
- # ---------------------------------------------------------------------------
550
- # Backend dispatch helpers
551
- # ---------------------------------------------------------------------------
552
-
553
-
554
- def backend_is_running(name: str) -> bool:
555
- if MODE == "tmux":
556
- return tmux_is_running(name)
557
- return screen_is_running(name)
558
-
559
-
560
- def backend_start_one(name: str) -> None:
561
- if MODE == "tmux":
562
- tmux_start_one(name)
563
- else:
564
- screen_start_one(name)
565
-
566
-
567
- def backend_stop_one(name: str) -> None:
568
- if MODE == "tmux":
569
- tmux_stop_one(name)
570
- else:
571
- screen_stop_one(name)
572
-
573
-
574
- def backend_status() -> None:
575
- if MODE == "tmux":
576
- tmux_status()
577
- else:
578
- screen_status()
579
-
580
-
581
- def backend_attach(name: str) -> None:
582
- if MODE == "tmux":
583
- tmux_attach(name)
584
- else:
585
- screen_attach(name)
586
-
587
-
588
- # ---------------------------------------------------------------------------
589
- # High-level commands
590
- # ---------------------------------------------------------------------------
591
-
592
-
593
- def do_start(args: list[str]) -> None:
594
- role_filter = ""
595
- exclude_filter = ""
596
- dry_run = False
16
+ def _parse_start_args(args: list[str]) -> tuple[list[str], dict]:
597
17
  names: list[str] = []
18
+ kwargs: dict = {"dry_run": False, "role": "", "exclude": ""}
598
19
  for arg in args:
599
20
  if arg == "--dry-run":
600
- dry_run = True
21
+ kwargs["dry_run"] = True
601
22
  elif arg.startswith("--role="):
602
- role_filter = arg.split("=", 1)[1]
23
+ kwargs["role"] = arg.split("=", 1)[1]
603
24
  elif arg.startswith("--exclude="):
604
- exclude_filter = arg.split("=", 1)[1]
25
+ kwargs["exclude"] = arg.split("=", 1)[1]
605
26
  else:
606
27
  names.append(arg)
28
+ return names, kwargs
607
29
 
608
- if not names:
609
- if role_filter or exclude_filter:
610
- targets = filter_by_role(role_filter, exclude_filter)
611
- else:
612
- targets = list(SESSIONS)
613
- else:
614
- targets = names
615
30
 
616
- if dry_run:
617
- print(f"=== DRY RUN: would start {len(targets)} session(s) ===")
618
- for name in targets:
619
- if is_suspended(name, SUSPENDED_FILE):
620
- print(f" {name}: SUSPENDED (would skip)")
621
- elif backend_is_running(name):
622
- print(f" {name}: already running (would skip)")
623
- else:
624
- print(f" {name}: would start (mode: {MODE}, agent: {SWARM_AGENT})")
625
- return
626
-
627
- _ensure_registered(targets)
628
-
629
- if MODE == "tmux":
630
- subprocess.run(["tmux", "set", "-g", "mouse", "on"], capture_output=True)
631
-
632
- started = 0
633
- for name in targets:
634
- if is_suspended(name, SUSPENDED_FILE):
635
- print(f" {name}: SUSPENDED (use './swarm resume {name}' to reactivate)")
636
- continue
637
- backend_start_one(name)
638
- clock_in(name)
639
- started += 1
640
-
641
- for t in _pending_threads:
642
- t.join(timeout=90)
643
- _pending_threads.clear()
644
-
645
- print()
646
- print(f"Mode: {MODE} | Agent: {SWARM_AGENT} | Started: {started}")
647
- print(f"Logs: {LOG_DIR}")
648
- if MODE == "tmux":
649
- print(f" tmux attach -t {PREFIX}-<name> # attach (Ctrl-B D to detach)")
650
- else:
651
- print(f" screen -r {PREFIX}-<name> # attach (Ctrl-A D to detach)")
652
- print(" ./swarm status # who's running")
653
-
654
-
655
- def do_status() -> None:
656
- print(f"=== Swarm Status (mode: {MODE}, agent: {SWARM_AGENT}) ===")
657
- backend_status()
658
-
659
-
660
- def do_stop(args: list[str]) -> None:
661
- dry_run = False
662
- force = False
31
+ def _parse_stop_args(args: list[str]) -> tuple[list[str], dict]:
663
32
  names: list[str] = []
33
+ kwargs: dict = {"dry_run": False, "force": False}
664
34
  for arg in args:
665
- if arg in ("--dry-run",):
666
- dry_run = True
35
+ if arg == "--dry-run":
36
+ kwargs["dry_run"] = True
667
37
  elif arg in ("--force", "--yes", "-y"):
668
- force = True
38
+ kwargs["force"] = True
669
39
  else:
670
40
  names.append(arg)
671
-
672
- if not names:
673
- targets = list(SESSIONS)
674
- else:
675
- targets = names
676
-
677
- if dry_run:
678
- print(f"=== DRY RUN: would stop {len(targets)} session(s) ===")
679
- for name in targets:
680
- if backend_is_running(name):
681
- print(f" {name}: would stop (running)")
682
- else:
683
- print(f" {name}: not running (would skip)")
684
- return
685
-
686
- # Confirmation when stopping all sessions
687
- if not names and not force:
688
- running_count = sum(1 for n in targets if backend_is_running(n))
689
- if running_count > 0:
690
- print(f"About to stop ALL sessions ({running_count} running).")
691
- try:
692
- answer = input("Continue? [y/N] ").strip().lower()
693
- except (EOFError, KeyboardInterrupt):
694
- print("\nCancelled.")
695
- return
696
- if answer not in ("y", "yes"):
697
- print("Cancelled.")
698
- return
699
-
700
- for name in targets:
701
- backend_stop_one(name)
702
- clock_out(name)
703
-
704
- # If stopping all sessions, also kill dispatcher by PID file
705
- if not names:
706
- for pidname in ("dispatcher.pid", "dispatcher-watchdog.pid"):
707
- pidfile = CLAUDES_DIR / pidname
708
- if pidfile.exists():
709
- try:
710
- pid = int(pidfile.read_text().strip())
711
- os.kill(pid, 0) # check alive
712
- os.kill(pid, 15) # SIGTERM
713
- except (ValueError, ProcessLookupError, PermissionError):
714
- pass
715
- pidfile.unlink(missing_ok=True)
716
-
717
-
718
- def do_attach(args: list[str]) -> None:
719
- if not args:
720
- print("Usage: ./swarm attach <name>", file=sys.stderr)
721
- sys.exit(1)
722
- backend_attach(args[0])
723
-
724
-
725
- def do_suspend(args: list[str]) -> None:
726
- if not args:
727
- print("Usage: ./swarm suspend <name> [names...]", file=sys.stderr)
728
- sys.exit(1)
729
- for name in args:
730
- if not is_valid_session(name):
731
- print(f" {name}: unknown session (valid: {' '.join(SESSIONS)})", file=sys.stderr)
732
- continue
733
- if is_suspended(name, SUSPENDED_FILE):
734
- print(f" {name}: already suspended")
735
- continue
736
- with open(SUSPENDED_FILE, "a") as f:
737
- f.write(name + "\n")
738
- if backend_is_running(name):
739
- backend_stop_one(name)
740
- clock_out(name)
741
- print(f" {name}: suspended")
742
-
743
-
744
- def do_resume(args: list[str]) -> None:
745
- if not args:
746
- print("Usage: ./swarm resume <name> [names...]", file=sys.stderr)
747
- sys.exit(1)
748
- for name in args:
749
- if not is_valid_session(name):
750
- print(f" {name}: unknown session (valid: {' '.join(SESSIONS)})", file=sys.stderr)
751
- continue
752
- if not is_suspended(name, SUSPENDED_FILE):
753
- print(f" {name}: not suspended")
754
- continue
755
- # Remove from suspended list
756
- if SUSPENDED_FILE.exists():
757
- lines = [l for l in SUSPENDED_FILE.read_text().splitlines() if l != name]
758
- SUSPENDED_FILE.write_text("\n".join(lines) + "\n" if lines else "")
759
- backend_start_one(name)
760
- time.sleep(1)
761
- if backend_is_running(name):
762
- clock_in(name)
763
- print(f" {name}: resumed + started")
764
- else:
765
- print(
766
- f" {name}: resumed but FAILED to start (check tmux/claude availability)",
767
- file=sys.stderr,
768
- )
769
-
770
-
771
- def do_restart(args: list[str]) -> None:
772
- if not args:
773
- targets = [n for n in SESSIONS if not is_suspended(n, SUSPENDED_FILE)]
774
- else:
775
- targets = args
776
-
777
- print(f"=== Restarting {len(targets)} session(s) ===")
778
- for name in targets:
779
- if is_suspended(name, SUSPENDED_FILE):
780
- print(f" {name}: SUSPENDED (use './swarm resume {name}' to reactivate)")
781
- continue
782
- backend_stop_one(name)
783
- time.sleep(1)
784
- backend_start_one(name)
785
- print(f" {name}: restarted")
786
-
787
-
788
- def do_morning() -> None:
789
- print("=== Morning startup (all non-intern, non-dispatcher) ===")
790
- targets: list[str] = []
791
- for name in SESSIONS:
792
- role = get_role(name)
793
- if role in ("intern", "dispatcher"):
794
- continue
795
- targets.append(name)
796
-
797
- resumed = 0
798
- started = 0
799
- failed = 0
800
- for name in targets:
801
- if is_suspended(name, SUSPENDED_FILE):
802
- # Remove from suspended list
803
- if SUSPENDED_FILE.exists():
804
- lines = [l for l in SUSPENDED_FILE.read_text().splitlines() if l != name]
805
- SUSPENDED_FILE.write_text("\n".join(lines) + "\n" if lines else "")
806
- resumed += 1
807
- backend_start_one(name)
808
- clock_in(name)
809
- if backend_is_running(name):
810
- started += 1
811
- else:
812
- failed += 1
813
- print(f" WARNING: {name} failed to start")
814
-
815
- print()
816
- print(f"Results: {started} started, {resumed} resumed from suspend, {failed} failed")
817
- print("Skipped roles: intern, dispatcher")
818
- print()
819
- print("To also start interns: ./swarm start --role=intern")
820
- print("To start dispatcher: ./swarm start <dispatcher>")
821
-
822
-
823
- def cmd_help() -> None:
824
- print(f"""\
825
- swarm — manage multi-agent session swarm
826
-
827
- Mode: {MODE} (override with SWARM_MODE=tmux|screen)
828
- Agent: {SWARM_AGENT} (override with SWARM_AGENT=claude|trae|qwen)
829
-
830
- start [names...] Launch sessions (default: all non-suspended)
831
- start --role=dev Launch only sessions with matching role
832
- start --exclude=intern Launch all except matching role
833
- status Who's running
834
- stop [names...] Gracefully stop sessions (default: all)
835
- restart [names...] Stop + start (default: all non-suspended)
836
- suspend <names...> Suspend sessions (stop + skip on future starts)
837
- resume <names...> Resume suspended sessions (remove from list + start)
838
- morning Resume + start all dev sessions (excludes intern/dispatcher)
839
- attach <name> Attach to session
840
- help This message
841
-
842
- Roles (from ROSTER.md): lead, dev, intern, dispatcher
843
-
844
- Examples:
845
- ./swarm start # launch all with Claude (default)
846
- SWARM_AGENT=trae ./swarm start # launch all with Trae
847
- SWARM_AGENT=qwen ./swarm start # launch all with Qwen
848
- SWARM_AGENT=qwen ./swarm start alice bob # launch specific sessions with Qwen
849
- ./swarm start alice bob # launch two with default agent
850
- ./swarm attach alice # interactive access
851
- ./swarm stop bob # stop one
852
- SWARM_MODE=screen ./swarm start # force screen mode""")
853
-
854
-
855
- # ---------------------------------------------------------------------------
856
- # Main
857
- # ---------------------------------------------------------------------------
41
+ return names, kwargs
858
42
 
859
43
 
860
44
  def main() -> None:
861
- _load()
862
- if MODE == "none":
863
- print(
864
- "ERROR: neither tmux nor screen found. Install one: brew install tmux",
865
- file=sys.stderr,
866
- )
867
- sys.exit(1)
45
+ cfg = SwarmConfig.load()
46
+ mgr = SwarmManager(cfg)
868
47
 
869
48
  cmd = sys.argv[1] if len(sys.argv) > 1 else "help"
870
49
  rest = sys.argv[2:]
871
50
 
872
- commands = {
873
- "start": lambda: do_start(rest),
874
- "status": lambda: do_status(),
875
- "stop": lambda: do_stop(rest),
876
- "restart": lambda: do_restart(rest),
877
- "suspend": lambda: do_suspend(rest),
878
- "resume": lambda: do_resume(rest),
879
- "morning": lambda: do_morning(),
880
- "attach": lambda: do_attach(rest),
881
- "attendance": lambda: do_attendance(),
882
- "help": cmd_help,
883
- "-h": cmd_help,
884
- "--help": cmd_help,
885
- }
886
-
887
- handler = commands.get(cmd)
888
- if handler:
889
- handler()
51
+ if cmd == "start":
52
+ names, kwargs = _parse_start_args(rest)
53
+ mgr.start(names, **kwargs)
54
+ elif cmd == "stop":
55
+ names, kwargs = _parse_stop_args(rest)
56
+ mgr.stop(names, **kwargs)
57
+ elif cmd == "status":
58
+ mgr.status()
59
+ elif cmd == "restart":
60
+ mgr.restart(rest)
61
+ elif cmd == "suspend":
62
+ mgr.suspend(rest)
63
+ elif cmd == "resume":
64
+ mgr.resume(rest)
65
+ elif cmd == "attach":
66
+ mgr.attach(rest[0] if rest else "")
67
+ elif cmd == "attendance":
68
+ mgr.attendance()
69
+ elif cmd in ("help", "-h", "--help"):
70
+ mgr.help()
890
71
  else:
891
- print(f"Unknown: {cmd} (try ./swarm help)", file=sys.stderr)
892
- sys.exit(1)
72
+ print(f"Unknown: {cmd} (try swarm help)")
73
+ raise SystemExit(1)
893
74
 
894
75
 
895
76
  if __name__ == "__main__":