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/README.md +70 -33
- package/VERSION +1 -1
- package/bin/board +10 -0
- package/bin/cnb +109 -48
- package/bin/init +5 -39
- package/bin/swarm +41 -860
- package/lib/board_db.py +15 -137
- package/lib/board_lock.py +0 -1
- package/lib/board_mailbox.py +15 -7
- package/lib/board_maintenance.py +26 -27
- package/lib/board_msg.py +18 -69
- package/lib/board_task.py +35 -38
- package/lib/board_tui.py +112 -0
- package/lib/board_view.py +12 -24
- package/lib/concerns/__init__.py +4 -11
- package/lib/concerns/{coral_manager.py → coral.py} +53 -3
- package/lib/concerns/file_watcher.py +1 -1
- package/lib/concerns/health.py +136 -0
- package/lib/concerns/helpers.py +1 -5
- package/lib/concerns/idle.py +130 -0
- package/lib/concerns/notifications.py +90 -0
- package/lib/migrate.py +1 -0
- package/lib/monitor.py +1 -18
- package/lib/swarm.py +456 -0
- package/lib/swarm_backend.py +266 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/registry/pubkeys.json +2 -1
- package/lib/concerns/bug_sla_checker.py +0 -32
- package/lib/concerns/coral_poker.py +0 -57
- package/lib/concerns/health_checker.py +0 -72
- package/lib/concerns/idle_detector.py +0 -56
- package/lib/concerns/idle_killer.py +0 -41
- package/lib/concerns/idle_nudger.py +0 -38
- package/lib/concerns/inbox_nudger.py +0 -34
- package/lib/concerns/resource_monitor.py +0 -47
- package/lib/concerns/session_keepalive.py +0 -23
- package/lib/concerns/time_announcer.py +0 -34
package/bin/swarm
CHANGED
|
@@ -1,895 +1,76 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""swarm — Launch and manage multi-agent sessions
|
|
2
|
+
"""swarm — Launch and manage multi-agent sessions.
|
|
3
3
|
|
|
4
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
23
|
+
kwargs["role"] = arg.split("=", 1)[1]
|
|
603
24
|
elif arg.startswith("--exclude="):
|
|
604
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
862
|
-
|
|
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
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
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
|
|
892
|
-
|
|
72
|
+
print(f"Unknown: {cmd} (try swarm help)")
|
|
73
|
+
raise SystemExit(1)
|
|
893
74
|
|
|
894
75
|
|
|
895
76
|
if __name__ == "__main__":
|