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/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
|
-
|
|
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""")
|