agentainer 0.1.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 +603 -0
- package/agents.example.yaml +257 -0
- package/bin/agentainer.js +74 -0
- package/examples/bug-hunt.yaml +112 -0
- package/examples/existing-repo.yaml +72 -0
- package/examples/research-swarm.yaml +120 -0
- package/examples/software-company.yaml +152 -0
- package/hooks/claude_stop.sh +17 -0
- package/hooks/codex_notify.sh +16 -0
- package/lib/config.py +641 -0
- package/lib/minyaml.py +376 -0
- package/lib/swarm.py +2277 -0
- package/llms.txt +405 -0
- package/package.json +52 -0
- package/scripts/check-deps.js +76 -0
- package/swarm.sh +43 -0
package/lib/swarm.py
ADDED
|
@@ -0,0 +1,2277 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""AgentSwarm -- run a swarm of coding agents in tmux and let them talk.
|
|
3
|
+
|
|
4
|
+
Invoked through ``swarm.sh``; see ``swarm.sh --help`` and README.md.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import contextlib
|
|
11
|
+
import json
|
|
12
|
+
import os
|
|
13
|
+
import shlex
|
|
14
|
+
import re
|
|
15
|
+
import shutil
|
|
16
|
+
import subprocess
|
|
17
|
+
import sys
|
|
18
|
+
import tempfile
|
|
19
|
+
import time
|
|
20
|
+
import uuid
|
|
21
|
+
from datetime import datetime, timezone
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
try: # POSIX only, which is fine: tmux is too.
|
|
25
|
+
import fcntl
|
|
26
|
+
except ImportError: # pragma: no cover
|
|
27
|
+
fcntl = None # type: ignore
|
|
28
|
+
|
|
29
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
30
|
+
|
|
31
|
+
import config as cfgmod # noqa: E402
|
|
32
|
+
from config import Agent, ConfigError, SwarmConfig # noqa: E402
|
|
33
|
+
|
|
34
|
+
SWARM_HOME = Path(os.environ.get("SWARM_HOME") or Path(__file__).resolve().parents[1])
|
|
35
|
+
HOOKS_DIR = SWARM_HOME / "hooks"
|
|
36
|
+
|
|
37
|
+
# Agent types whose CLI can invoke an external program when a turn completes.
|
|
38
|
+
HOOK_CAPABLE = ("claude", "codex")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# --------------------------------------------------------------------------
|
|
42
|
+
# small utilities
|
|
43
|
+
# --------------------------------------------------------------------------
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class SwarmError(Exception):
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def now_iso() -> str:
|
|
51
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def info(msg: str) -> None:
|
|
55
|
+
print(f"\033[36m::\033[0m {msg}", file=sys.stderr)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def warn(msg: str) -> None:
|
|
59
|
+
print(f"\033[33m!!\033[0m {msg}", file=sys.stderr)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def die(msg: str) -> "None":
|
|
63
|
+
print(f"\033[31mxx\033[0m {msg}", file=sys.stderr)
|
|
64
|
+
raise SystemExit(1)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def sleep_ms(ms: int) -> None:
|
|
68
|
+
if ms > 0:
|
|
69
|
+
time.sleep(ms / 1000.0)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
# --------------------------------------------------------------------------
|
|
73
|
+
# tmux
|
|
74
|
+
# --------------------------------------------------------------------------
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def tmux(*args: str, check: bool = True, capture: bool = False) -> subprocess.CompletedProcess:
|
|
78
|
+
if not shutil.which("tmux"):
|
|
79
|
+
raise SwarmError("tmux is not installed or not on PATH")
|
|
80
|
+
return subprocess.run(
|
|
81
|
+
["tmux", *args],
|
|
82
|
+
check=check,
|
|
83
|
+
text=True,
|
|
84
|
+
stdout=subprocess.PIPE if capture else None,
|
|
85
|
+
stderr=subprocess.PIPE if capture else None,
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
LOCK_TIMEOUT_S = 180
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@contextlib.contextmanager
|
|
93
|
+
def file_lock(cfg: SwarmConfig, name: str, what: str = "lock"):
|
|
94
|
+
"""An advisory cross-process lock, used to serialise access to one pane/queue.
|
|
95
|
+
|
|
96
|
+
Lock ordering, to keep it deadlock-free: queue -> pane -> turn state.
|
|
97
|
+
"""
|
|
98
|
+
if fcntl is None: # pragma: no cover
|
|
99
|
+
yield
|
|
100
|
+
return
|
|
101
|
+
|
|
102
|
+
cfg.run_dir.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
handle = open(cfg.run_dir / f"{name}.{what}", "w")
|
|
104
|
+
deadline = time.monotonic() + LOCK_TIMEOUT_S
|
|
105
|
+
locked = False
|
|
106
|
+
try:
|
|
107
|
+
while True:
|
|
108
|
+
try:
|
|
109
|
+
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
110
|
+
locked = True
|
|
111
|
+
break
|
|
112
|
+
except OSError:
|
|
113
|
+
if time.monotonic() > deadline:
|
|
114
|
+
warn(f"{name}: timed out waiting for the {what}; proceeding anyway")
|
|
115
|
+
break
|
|
116
|
+
time.sleep(0.05)
|
|
117
|
+
yield
|
|
118
|
+
finally:
|
|
119
|
+
if locked:
|
|
120
|
+
with contextlib.suppress(OSError):
|
|
121
|
+
fcntl.flock(handle, fcntl.LOCK_UN)
|
|
122
|
+
handle.close()
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def pane_lock(cfg: SwarmConfig, session: str):
|
|
126
|
+
"""Serialise everything that types into one pane.
|
|
127
|
+
|
|
128
|
+
A paste and the Enter that submits it are two separate tmux calls. Without a
|
|
129
|
+
lock, a second sender -- another agent, or one of several subagents running
|
|
130
|
+
in parallel inside the same agent -- can paste in between them, so one Enter
|
|
131
|
+
submits two concatenated messages and the other submits nothing.
|
|
132
|
+
|
|
133
|
+
The busy check and the "mark this agent busy" write also happen under this
|
|
134
|
+
lock, so two concurrent senders cannot both observe an idle agent and both
|
|
135
|
+
deliver to it.
|
|
136
|
+
|
|
137
|
+
The lock is per recipient, so unrelated agents are still messaged in parallel.
|
|
138
|
+
"""
|
|
139
|
+
return file_lock(cfg, session, "pane.lock")
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# --------------------------------------------------------------------------
|
|
143
|
+
# turn state: is an agent mid-task?
|
|
144
|
+
# --------------------------------------------------------------------------
|
|
145
|
+
#
|
|
146
|
+
# We know when a turn starts (we pressed Enter) and when one ends (the capture
|
|
147
|
+
# hook fires). Two counters rather than a boolean, because a bare flag is racy:
|
|
148
|
+
# the hook that ends turn N can land after a message delivered *during* turn N,
|
|
149
|
+
# and would then clear a "busy" that belongs to the next turn.
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
class BusyError(SwarmError):
|
|
153
|
+
"""The recipient is mid-task and is not accepting messages right now."""
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def turn_state(cfg: SwarmConfig, agent: str) -> dict:
|
|
157
|
+
try:
|
|
158
|
+
return json.loads((cfg.run_dir / f"{agent}.turn.json").read_text())
|
|
159
|
+
except (OSError, json.JSONDecodeError):
|
|
160
|
+
return {"delivered": 0, "completed": 0, "since": 0, "by": None}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def write_turn_state(cfg: SwarmConfig, agent: str, state: dict) -> None:
|
|
164
|
+
cfg.run_dir.mkdir(parents=True, exist_ok=True)
|
|
165
|
+
(cfg.run_dir / f"{agent}.turn.json").write_text(json.dumps(state))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def busy_info(cfg: SwarmConfig, agent: Agent) -> dict | None:
|
|
169
|
+
"""Return the turn state if *agent* is mid-task, else None. Not locked."""
|
|
170
|
+
if not agent.busy_check:
|
|
171
|
+
return None
|
|
172
|
+
state = turn_state(cfg, agent.name)
|
|
173
|
+
if state.get("delivered", 0) <= state.get("completed", 0):
|
|
174
|
+
return None
|
|
175
|
+
|
|
176
|
+
age_ms = (time.time() - state.get("since", 0)) * 1000
|
|
177
|
+
if age_ms > cfg.busy_timeout_ms:
|
|
178
|
+
# The hook never fired -- crashed agent, killed CLI, capture misconfigured.
|
|
179
|
+
# Fail open rather than wedge the swarm forever.
|
|
180
|
+
warn(
|
|
181
|
+
f"{agent.name}: has looked busy for {int(age_ms / 1000)}s "
|
|
182
|
+
f"(over busy_timeout_ms); treating it as idle"
|
|
183
|
+
)
|
|
184
|
+
return None
|
|
185
|
+
state["age_s"] = int(age_ms / 1000)
|
|
186
|
+
return state
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def mark_turn_started(cfg: SwarmConfig, agent: str, sender: str) -> None:
|
|
190
|
+
state = turn_state(cfg, agent)
|
|
191
|
+
state["delivered"] = state.get("delivered", 0) + 1
|
|
192
|
+
state["since"] = time.time()
|
|
193
|
+
state["by"] = sender
|
|
194
|
+
write_turn_state(cfg, agent, state)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def mark_turn_finished(cfg: SwarmConfig, agent: str) -> None:
|
|
198
|
+
"""The agent finished a turn: everything submitted so far is consumed.
|
|
199
|
+
|
|
200
|
+
Clamping (rather than incrementing) keeps the counters from drifting when a
|
|
201
|
+
CLI folds a queued message into the turn already running -- codex does this,
|
|
202
|
+
printing "messages to be submitted after next tool call". Drift would leave
|
|
203
|
+
an agent permanently "busy".
|
|
204
|
+
"""
|
|
205
|
+
with file_lock(cfg, agent, "turn.lock"):
|
|
206
|
+
state = turn_state(cfg, agent)
|
|
207
|
+
state["completed"] = state.get("delivered", 0)
|
|
208
|
+
write_turn_state(cfg, agent, state)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def busy_message(cfg: SwarmConfig, agent: Agent, state: dict) -> str:
|
|
212
|
+
by = state.get("by") or "someone"
|
|
213
|
+
return (
|
|
214
|
+
f"{agent.name} is busy right now (working for {state['age_s']}s on a task "
|
|
215
|
+
f"from {by}). Please try again after some time, or put your message in the "
|
|
216
|
+
f"queue and wait for the answer:\n"
|
|
217
|
+
f" swarm send --to {agent.name} --queue \"...\" "
|
|
218
|
+
f"# delivered automatically when {agent.name} is free\n"
|
|
219
|
+
f" swarm send --to {agent.name} --wait \"...\" "
|
|
220
|
+
f"# block here until {agent.name} is free\n"
|
|
221
|
+
f"Meanwhile you are free to do other work."
|
|
222
|
+
)
|
|
223
|
+
|
|
224
|
+
|
|
225
|
+
# --------------------------------------------------------------------------
|
|
226
|
+
# per-agent message queue
|
|
227
|
+
# --------------------------------------------------------------------------
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def queue_path(cfg: SwarmConfig, agent: str) -> Path:
|
|
231
|
+
return cfg.run_dir / f"{agent}.queue.jsonl"
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def queue_read(cfg: SwarmConfig, agent: str) -> list[dict]:
|
|
235
|
+
try:
|
|
236
|
+
return [json.loads(l) for l in queue_path(cfg, agent).read_text().splitlines() if l.strip()]
|
|
237
|
+
except (OSError, json.JSONDecodeError):
|
|
238
|
+
return []
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def queue_write(cfg: SwarmConfig, agent: str, items: list[dict]) -> None:
|
|
242
|
+
cfg.run_dir.mkdir(parents=True, exist_ok=True)
|
|
243
|
+
queue_path(cfg, agent).write_text("".join(json.dumps(i) + "\n" for i in items))
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def enqueue(
|
|
247
|
+
cfg: SwarmConfig,
|
|
248
|
+
sender: str,
|
|
249
|
+
recipient: str,
|
|
250
|
+
text: str,
|
|
251
|
+
hops: int,
|
|
252
|
+
reply_to: str | None = None,
|
|
253
|
+
expects_reply: bool = True,
|
|
254
|
+
) -> tuple[str, int]:
|
|
255
|
+
item_id = f"{time.time():.6f}-{os.getpid()}"
|
|
256
|
+
with file_lock(cfg, recipient, "queue.lock"):
|
|
257
|
+
items = queue_read(cfg, recipient)
|
|
258
|
+
items.append(
|
|
259
|
+
{
|
|
260
|
+
"id": item_id,
|
|
261
|
+
"from": sender,
|
|
262
|
+
"text": text,
|
|
263
|
+
"hops": hops,
|
|
264
|
+
"reply_to": reply_to,
|
|
265
|
+
"expects_reply": expects_reply,
|
|
266
|
+
"ts": now_iso(),
|
|
267
|
+
}
|
|
268
|
+
)
|
|
269
|
+
queue_write(cfg, recipient, items)
|
|
270
|
+
depth = len(items)
|
|
271
|
+
log_event(cfg, recipient, "queued", **{"from": sender}, depth=depth, text=text)
|
|
272
|
+
return item_id, depth
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def drain_queue(cfg: SwarmConfig, agent: Agent) -> bool:
|
|
276
|
+
"""Deliver the next queued message, now that the agent has gone idle.
|
|
277
|
+
|
|
278
|
+
Returns True if one was handed over -- the agent is then busy again, and the
|
|
279
|
+
rest of the queue waits for its next turn to end.
|
|
280
|
+
"""
|
|
281
|
+
with file_lock(cfg, agent.name, "queue.lock"):
|
|
282
|
+
items = queue_read(cfg, agent.name)
|
|
283
|
+
if not items:
|
|
284
|
+
return False
|
|
285
|
+
head = items[0]
|
|
286
|
+
try:
|
|
287
|
+
deliver(
|
|
288
|
+
cfg,
|
|
289
|
+
head["from"],
|
|
290
|
+
agent.name,
|
|
291
|
+
head["text"],
|
|
292
|
+
hops=head.get("hops", 0),
|
|
293
|
+
enforce_acl=False, # it was checked when the message was queued
|
|
294
|
+
reply_to=head.get("reply_to"),
|
|
295
|
+
expects_reply=head.get("expects_reply", True),
|
|
296
|
+
)
|
|
297
|
+
except BusyError:
|
|
298
|
+
return False # somebody else got there first; try again at the next idle
|
|
299
|
+
except SwarmError as exc:
|
|
300
|
+
warn(f"{agent.name}: could not deliver queued message: {exc}")
|
|
301
|
+
return False
|
|
302
|
+
queue_write(cfg, agent.name, items[1:])
|
|
303
|
+
info(f"{agent.name}: delivered queued message from {head['from']} ({len(items) - 1} left)")
|
|
304
|
+
return True
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def sweep_stale_queues(cfg: SwarmConfig, exclude: str | None = None) -> None:
|
|
308
|
+
"""Drain queued mail for any *other* agent that is no longer busy.
|
|
309
|
+
|
|
310
|
+
A message is queued when its recipient is busy, and normally drains when that
|
|
311
|
+
recipient finishes its own turn. But if the recipient's capture never fires --
|
|
312
|
+
a crashed CLI, or a `type` whose hook does not match the `command` actually
|
|
313
|
+
running -- that turn end never arrives and the message is stranded forever.
|
|
314
|
+
Since turn completions are the only thing that wakes this process, every one is
|
|
315
|
+
an opportunity to also hand over anything stuck for an agent that has since gone
|
|
316
|
+
idle (busy_info fails a stale-busy agent open once busy_timeout_ms passes).
|
|
317
|
+
"""
|
|
318
|
+
for other in cfg.agents:
|
|
319
|
+
if other.name == exclude:
|
|
320
|
+
continue
|
|
321
|
+
if not queue_read(cfg, other.name):
|
|
322
|
+
continue
|
|
323
|
+
if busy_info(cfg, other) is not None:
|
|
324
|
+
continue # legitimately mid-turn; its own turn end will drain it
|
|
325
|
+
try:
|
|
326
|
+
drain_queue(cfg, other)
|
|
327
|
+
except SwarmError as exc:
|
|
328
|
+
warn(f"{other.name}: could not drain stranded queue: {exc}")
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def configure_tmux(cfg: SwarmConfig) -> str | None:
|
|
332
|
+
"""Set the globals the swarm's panes inherit, before any agent pane is created.
|
|
333
|
+
|
|
334
|
+
history-limit is only consulted when a pane is spawned, and the default (2000
|
|
335
|
+
lines) is far too small to hold a long multi-agent conversation -- the user
|
|
336
|
+
attaches, tries to scroll up, and the early messages are already gone. mouse
|
|
337
|
+
mode lets the wheel scroll that backlog.
|
|
338
|
+
|
|
339
|
+
Both are global options, but a tmux server with no sessions exits immediately,
|
|
340
|
+
so `set -g` on a cold server (the normal state at `up`) does nothing. We hold
|
|
341
|
+
the server up with a throwaway session while setting them; the agent panes
|
|
342
|
+
created afterwards inherit the values. Returns the holder session name so the
|
|
343
|
+
caller can tear it down once real sessions keep the server alive; None if there
|
|
344
|
+
was nothing to configure. Best effort throughout: never block the swarm coming up.
|
|
345
|
+
"""
|
|
346
|
+
if cfg.tmux_history_limit <= 0 and not cfg.tmux_mouse:
|
|
347
|
+
return None
|
|
348
|
+
holder = f"{cfg.session_prefix}swarm_setup"
|
|
349
|
+
tmux("new-session", "-d", "-s", holder, "sleep 86400", check=False)
|
|
350
|
+
if cfg.tmux_history_limit > 0:
|
|
351
|
+
tmux("set-option", "-g", "history-limit", str(cfg.tmux_history_limit), check=False)
|
|
352
|
+
if cfg.tmux_mouse:
|
|
353
|
+
tmux("set-option", "-g", "mouse", "on", check=False)
|
|
354
|
+
return holder
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
def session_exists(session: str) -> bool:
|
|
358
|
+
try:
|
|
359
|
+
tmux("has-session", "-t", f"={session}", capture=True)
|
|
360
|
+
return True
|
|
361
|
+
except subprocess.CalledProcessError:
|
|
362
|
+
return False
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
PASTE_ATTEMPTS = 2
|
|
366
|
+
VERIFY_TIMEOUT_MS = 3000
|
|
367
|
+
VERIFY_SCROLLBACK = 200
|
|
368
|
+
NEEDLE_LEN = 28
|
|
369
|
+
# Both CLIs collapse a long paste into a chip instead of showing the text:
|
|
370
|
+
# claude -> "[Pasted text #1 +36 lines]"
|
|
371
|
+
# codex -> "[Pasted Content 2580 chars]"
|
|
372
|
+
# Whitespace is stripped before matching, so this sees "Pastedtext" / "PastedContent".
|
|
373
|
+
PASTE_CHIP_RE = re.compile(r"pasted(?:text|content)", re.IGNORECASE)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def pane_text(session: str, scrollback: int = 0) -> str:
|
|
377
|
+
args = ["capture-pane", "-p", "-t", session]
|
|
378
|
+
if scrollback:
|
|
379
|
+
args[2:2] = ["-S", f"-{scrollback}"]
|
|
380
|
+
try:
|
|
381
|
+
return tmux(*args, capture=True).stdout or ""
|
|
382
|
+
except subprocess.CalledProcessError:
|
|
383
|
+
return ""
|
|
384
|
+
|
|
385
|
+
|
|
386
|
+
def visible_pane(session: str) -> str:
|
|
387
|
+
return pane_text(session)
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def needle_for(body: str) -> str:
|
|
391
|
+
"""The *tail* of the text, which is what stays on screen after a paste.
|
|
392
|
+
|
|
393
|
+
Using the head would be wrong: a long prompt pushes its own first line out
|
|
394
|
+
of the pane, and the cursor -- hence the visible end of the text -- sits at
|
|
395
|
+
the bottom.
|
|
396
|
+
"""
|
|
397
|
+
return normalise(body)[-NEEDLE_LEN:]
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def paste_score(session: str, needle: str) -> int:
|
|
401
|
+
"""How many times the text we are about to send already appears on screen."""
|
|
402
|
+
pane = normalise(pane_text(session, VERIFY_SCROLLBACK))
|
|
403
|
+
return pane.count(needle) + len(PASTE_CHIP_RE.findall(pane))
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def send_buffer(session: str, body: str) -> None:
|
|
407
|
+
buf = f"swarm-{os.getpid()}-{int(time.time() * 1000)}"
|
|
408
|
+
with tempfile.NamedTemporaryFile("w", suffix=".txt", delete=False) as fh:
|
|
409
|
+
fh.write(body)
|
|
410
|
+
tmp = fh.name
|
|
411
|
+
try:
|
|
412
|
+
tmux("load-buffer", "-b", buf, tmp)
|
|
413
|
+
tmux("paste-buffer", "-b", buf, "-d", "-p", "-t", session)
|
|
414
|
+
finally:
|
|
415
|
+
os.unlink(tmp)
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
def paste_into(
|
|
419
|
+
cfg: SwarmConfig, session: str, text: str, enter: bool = True, needle: str | None = None
|
|
420
|
+
) -> bool:
|
|
421
|
+
"""Type *text* into a session's pane, confirm it arrived, then press Enter.
|
|
422
|
+
|
|
423
|
+
Bracketed paste (``paste-buffer -p``) lets a multi-line prompt land in the
|
|
424
|
+
agent's input box as one block instead of being submitted line by line.
|
|
425
|
+
|
|
426
|
+
Confirming matters: a TUI can silently discard keystrokes while it is still
|
|
427
|
+
starting up, and Claude Code does exactly that for several seconds partway
|
|
428
|
+
through boot. So we compare the pane before and after the paste, retry if
|
|
429
|
+
the text never showed up, and only press Enter once it has -- which also
|
|
430
|
+
means a retry cannot submit a half-delivered prompt.
|
|
431
|
+
"""
|
|
432
|
+
body = text.rstrip("\n")
|
|
433
|
+
if not body:
|
|
434
|
+
return False
|
|
435
|
+
if not session_exists(session):
|
|
436
|
+
raise SwarmError(f"tmux session {session!r} is not running")
|
|
437
|
+
|
|
438
|
+
with pane_lock(cfg, session):
|
|
439
|
+
return _paste_locked(cfg, session, body, enter, needle)
|
|
440
|
+
|
|
441
|
+
|
|
442
|
+
def _paste_locked(
|
|
443
|
+
cfg: SwarmConfig, session: str, body: str, enter: bool, needle: str | None = None
|
|
444
|
+
) -> bool:
|
|
445
|
+
needle = needle or needle_for(body)
|
|
446
|
+
delivered = False
|
|
447
|
+
|
|
448
|
+
for attempt in range(1, PASTE_ATTEMPTS + 1):
|
|
449
|
+
if attempt > 1:
|
|
450
|
+
# Best effort: clear anything a previous attempt may have left behind,
|
|
451
|
+
# so a retry cannot concatenate two copies of the prompt.
|
|
452
|
+
tmux("send-keys", "-t", session, "C-u", check=False)
|
|
453
|
+
sleep_ms(300)
|
|
454
|
+
|
|
455
|
+
before = paste_score(session, needle)
|
|
456
|
+
sleep_ms(cfg.send_delay_ms)
|
|
457
|
+
send_buffer(session, body)
|
|
458
|
+
|
|
459
|
+
deadline = time.monotonic() + VERIFY_TIMEOUT_MS / 1000.0
|
|
460
|
+
while time.monotonic() < deadline:
|
|
461
|
+
sleep_ms(200)
|
|
462
|
+
if paste_score(session, needle) > before:
|
|
463
|
+
delivered = True
|
|
464
|
+
break
|
|
465
|
+
if delivered:
|
|
466
|
+
break
|
|
467
|
+
warn(f"{session}: pasted text never appeared (attempt {attempt}/{PASTE_ATTEMPTS})")
|
|
468
|
+
|
|
469
|
+
if not delivered:
|
|
470
|
+
# Do not press Enter: if the text did arrive and we simply failed to see
|
|
471
|
+
# it, submitting now could send a mangled or duplicated prompt.
|
|
472
|
+
warn(f"{session}: could not confirm the text arrived; NOT pressing Enter")
|
|
473
|
+
return False
|
|
474
|
+
|
|
475
|
+
if enter:
|
|
476
|
+
sleep_ms(cfg.enter_delay_ms)
|
|
477
|
+
tmux("send-keys", "-t", session, "Enter")
|
|
478
|
+
return True
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
# --------------------------------------------------------------------------
|
|
482
|
+
# state / logging / inbox
|
|
483
|
+
# --------------------------------------------------------------------------
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def write_state(cfg: SwarmConfig) -> None:
|
|
487
|
+
state = {
|
|
488
|
+
"swarm": cfg.name,
|
|
489
|
+
"config": str(cfg.path),
|
|
490
|
+
"root": str(cfg.root),
|
|
491
|
+
"session_prefix": cfg.session_prefix,
|
|
492
|
+
"started_at": now_iso(),
|
|
493
|
+
"agents": {
|
|
494
|
+
a.name: {
|
|
495
|
+
"session": a.session,
|
|
496
|
+
"type": a.type,
|
|
497
|
+
"workdir": str(a.workdir),
|
|
498
|
+
"capture": a.capture,
|
|
499
|
+
"can_talk_to": a.can_talk_to,
|
|
500
|
+
}
|
|
501
|
+
for a in cfg.agents
|
|
502
|
+
},
|
|
503
|
+
}
|
|
504
|
+
(cfg.runtime / "state.json").write_text(json.dumps(state, indent=2) + "\n")
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
# --------------------------------------------------------------------------
|
|
508
|
+
# sessions.yaml -- the conversation id of every agent, so `up --resume` works
|
|
509
|
+
# --------------------------------------------------------------------------
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def yaml_scalar(value) -> str:
|
|
513
|
+
if value is None:
|
|
514
|
+
return "null"
|
|
515
|
+
if isinstance(value, bool):
|
|
516
|
+
return "true" if value else "false"
|
|
517
|
+
if isinstance(value, (int, float)):
|
|
518
|
+
return str(value)
|
|
519
|
+
text = str(value).replace("\\", "\\\\").replace('"', '\\"')
|
|
520
|
+
return f'"{text}"'
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def yaml_dump(data: dict, indent: int = 0) -> str:
|
|
524
|
+
"""Emit the small subset we need. Written by hand so PyYAML stays optional."""
|
|
525
|
+
pad = " " * indent
|
|
526
|
+
out = []
|
|
527
|
+
for key, value in data.items():
|
|
528
|
+
if isinstance(value, dict):
|
|
529
|
+
out.append(f"{pad}{key}:")
|
|
530
|
+
out.append(yaml_dump(value, indent + 2) if value else f"{pad} {{}}")
|
|
531
|
+
else:
|
|
532
|
+
out.append(f"{pad}{key}: {yaml_scalar(value)}")
|
|
533
|
+
return "\n".join(out)
|
|
534
|
+
|
|
535
|
+
|
|
536
|
+
def read_sessions(cfg: SwarmConfig) -> dict:
|
|
537
|
+
"""The agents block of sessions.yaml, or {} if it is missing or unreadable."""
|
|
538
|
+
try:
|
|
539
|
+
data = cfgmod.parse_yaml(cfg.sessions_file.read_text())
|
|
540
|
+
except OSError:
|
|
541
|
+
return {}
|
|
542
|
+
except Exception as exc: # noqa: BLE001 - a corrupt file must not stop the swarm
|
|
543
|
+
warn(f"could not parse {cfg.sessions_file}: {exc}")
|
|
544
|
+
return {}
|
|
545
|
+
if not isinstance(data, dict):
|
|
546
|
+
return {}
|
|
547
|
+
return data.get("agents") or {}
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
def write_sessions(cfg: SwarmConfig, agents: dict) -> None:
|
|
551
|
+
cfg.runtime.mkdir(parents=True, exist_ok=True)
|
|
552
|
+
header = (
|
|
553
|
+
"# AgentSwarm session state -- written automatically as agents work.\n"
|
|
554
|
+
"# `swarm up --resume` reads this to reattach each agent to its own\n"
|
|
555
|
+
"# conversation after a restart. Safe to delete; you then start fresh.\n"
|
|
556
|
+
)
|
|
557
|
+
body = yaml_dump(
|
|
558
|
+
{
|
|
559
|
+
"swarm": cfg.name,
|
|
560
|
+
"config": str(cfg.path),
|
|
561
|
+
"updated_at": now_iso(),
|
|
562
|
+
"agents": agents or {},
|
|
563
|
+
}
|
|
564
|
+
)
|
|
565
|
+
tmp = cfg.sessions_file.with_suffix(".yaml.tmp")
|
|
566
|
+
tmp.write_text(header + body + "\n")
|
|
567
|
+
os.replace(tmp, cfg.sessions_file) # atomic: hooks write this concurrently
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def record_session(cfg: SwarmConfig, agent: Agent, session_id, **fields) -> None:
|
|
571
|
+
"""Merge this agent's conversation id into sessions.yaml, under a lock."""
|
|
572
|
+
if not session_id:
|
|
573
|
+
return
|
|
574
|
+
with file_lock(cfg, "sessions", "lock"):
|
|
575
|
+
agents = read_sessions(cfg)
|
|
576
|
+
entry = agents.get(agent.name) or {}
|
|
577
|
+
if entry.get("session_id") == session_id:
|
|
578
|
+
return # unchanged: do not rewrite the file after every single turn
|
|
579
|
+
entry.update({k: v for k, v in fields.items() if v})
|
|
580
|
+
entry["session_id"] = session_id
|
|
581
|
+
entry["type"] = agent.type
|
|
582
|
+
entry["workdir"] = str(agent.workdir)
|
|
583
|
+
entry["updated_at"] = now_iso()
|
|
584
|
+
agents[agent.name] = entry
|
|
585
|
+
write_sessions(cfg, agents)
|
|
586
|
+
info(f"{agent.name}: recorded conversation {session_id}")
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
def codex_session(agent: Agent) -> tuple[str | None, str | None]:
|
|
590
|
+
"""Find the id of the codex conversation running in this agent's CODEX_HOME.
|
|
591
|
+
|
|
592
|
+
Codex does not hand its session id to the notify program, but it writes one
|
|
593
|
+
rollout file per session under CODEX_HOME/sessions, and the newest of those is
|
|
594
|
+
the conversation currently in progress.
|
|
595
|
+
"""
|
|
596
|
+
sessions = agent.workdir / ".codex" / "sessions"
|
|
597
|
+
if not sessions.is_dir():
|
|
598
|
+
return None, None
|
|
599
|
+
|
|
600
|
+
rollouts = sorted(sessions.rglob("rollout-*.jsonl"), key=lambda p: p.stat().st_mtime)
|
|
601
|
+
if not rollouts:
|
|
602
|
+
return None, None
|
|
603
|
+
|
|
604
|
+
newest = rollouts[-1]
|
|
605
|
+
try:
|
|
606
|
+
with newest.open() as fh:
|
|
607
|
+
first = fh.readline()
|
|
608
|
+
record = json.loads(first)
|
|
609
|
+
if record.get("type") == "session_meta":
|
|
610
|
+
payload = record.get("payload", {})
|
|
611
|
+
return payload.get("session_id") or payload.get("id"), str(newest)
|
|
612
|
+
except (OSError, json.JSONDecodeError):
|
|
613
|
+
pass
|
|
614
|
+
return None, str(newest)
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def log_event(cfg: SwarmConfig, agent: str, kind: str, **fields) -> None:
|
|
618
|
+
cfg.log_dir.mkdir(parents=True, exist_ok=True)
|
|
619
|
+
record = {"ts": now_iso(), "agent": agent, "kind": kind, **fields}
|
|
620
|
+
with (cfg.log_dir / f"{agent}.jsonl").open("a") as fh:
|
|
621
|
+
fh.write(json.dumps(record) + "\n")
|
|
622
|
+
with (cfg.log_dir / "swarm.jsonl").open("a") as fh:
|
|
623
|
+
fh.write(json.dumps(record) + "\n")
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def archive_message(
|
|
627
|
+
cfg: SwarmConfig,
|
|
628
|
+
sender: str,
|
|
629
|
+
recipient: str,
|
|
630
|
+
text: str,
|
|
631
|
+
msg_id: str = "",
|
|
632
|
+
reply_to: str | None = None,
|
|
633
|
+
) -> Path:
|
|
634
|
+
box = cfg.inbox_dir / recipient
|
|
635
|
+
box.mkdir(parents=True, exist_ok=True)
|
|
636
|
+
stamp = datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%S%f")[:-3]
|
|
637
|
+
suffix = f"-{msg_id}" if msg_id else ""
|
|
638
|
+
path = box / f"{stamp}-from-{sender}{suffix}.md"
|
|
639
|
+
head = f"# message from {sender}\n\n_{now_iso()}_"
|
|
640
|
+
if msg_id:
|
|
641
|
+
head += f" \nid: `{msg_id}`"
|
|
642
|
+
if reply_to:
|
|
643
|
+
head += f" \nin reply to: `{reply_to}`"
|
|
644
|
+
path.write_text(f"{head}\n\n{text.rstrip()}\n")
|
|
645
|
+
return path
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
MESSAGE_HEADER = "[swarm] message from"
|
|
649
|
+
ECHO_MEMORY = 300
|
|
650
|
+
|
|
651
|
+
# --------------------------------------------------------------------------
|
|
652
|
+
# tagged message envelopes
|
|
653
|
+
# --------------------------------------------------------------------------
|
|
654
|
+
#
|
|
655
|
+
# Inbound, an agent sees:
|
|
656
|
+
#
|
|
657
|
+
# <swarm-message from="lead" id="m-3f9a1c" reply-to="m-1b77e0">
|
|
658
|
+
# ...body, any number of lines...
|
|
659
|
+
# </swarm-message>
|
|
660
|
+
#
|
|
661
|
+
# Outbound, an agent simply writes this in its reply and the capture hook routes
|
|
662
|
+
# it. No shell quoting, so multi-line bodies survive intact:
|
|
663
|
+
#
|
|
664
|
+
# <swarm-send to="reviewer" reply-to="m-3f9a1c">
|
|
665
|
+
# ...body...
|
|
666
|
+
# </swarm-send>
|
|
667
|
+
|
|
668
|
+
INBOUND_TAG = "swarm-message"
|
|
669
|
+
OUTBOUND_RE = re.compile(
|
|
670
|
+
r"<swarm-(?P<kind>send|broadcast)\b(?P<attrs>[^>]*)>(?P<body>.*?)</swarm-(?P=kind)\s*>",
|
|
671
|
+
re.DOTALL | re.IGNORECASE,
|
|
672
|
+
)
|
|
673
|
+
ATTR_RE = re.compile(r"""([A-Za-z_-]+)\s*=\s*["']([^"']*)["']""")
|
|
674
|
+
# Every opening tag, capturing its attributes and whether it ends its own line.
|
|
675
|
+
OPENER_RE = re.compile(
|
|
676
|
+
r"<swarm-(?:send|broadcast)\b(?P<attrs>[^>]*)>(?P<tail>[ \t]*)(?P<nl>\n?)",
|
|
677
|
+
re.IGNORECASE,
|
|
678
|
+
)
|
|
679
|
+
ADDRESSED_RE = re.compile(r"\b(?:to|agent)\s*=", re.IGNORECASE)
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def _is_block_opener(attrs: str, ends_line: bool) -> bool:
|
|
683
|
+
"""Does this opening tag look like the real start of a message block?
|
|
684
|
+
|
|
685
|
+
A genuine send carries a `to="..."`, and any real block opener sits at the end
|
|
686
|
+
of its line, the way the template shows it. A bare inline `<swarm-send>` with
|
|
687
|
+
neither -- `Use \\`<swarm-send>\\` blocks`, `I'll <swarm-send> the result` -- is
|
|
688
|
+
the agent naming the tag in prose, not attempting a send, and must not trigger a
|
|
689
|
+
spurious "your send failed" nudge.
|
|
690
|
+
"""
|
|
691
|
+
return bool(ADDRESSED_RE.search(attrs)) or ends_line
|
|
692
|
+
|
|
693
|
+
|
|
694
|
+
def new_message_id() -> str:
|
|
695
|
+
return f"m-{uuid.uuid4().hex[:6]}"
|
|
696
|
+
|
|
697
|
+
|
|
698
|
+
def format_envelope(
|
|
699
|
+
cfg: SwarmConfig,
|
|
700
|
+
sender: str,
|
|
701
|
+
recipient: str,
|
|
702
|
+
text: str,
|
|
703
|
+
msg_id: str,
|
|
704
|
+
reply_to: str | None,
|
|
705
|
+
) -> str:
|
|
706
|
+
if cfg.message_format == "plain":
|
|
707
|
+
return f"{MESSAGE_HEADER} {sender}:\n{text}"
|
|
708
|
+
|
|
709
|
+
attrs = f'from="{sender}" to="{recipient}" id="{msg_id}"'
|
|
710
|
+
if reply_to:
|
|
711
|
+
attrs += f' reply-to="{reply_to}"'
|
|
712
|
+
return f"<{INBOUND_TAG} {attrs}>\n{text}\n</{INBOUND_TAG}>"
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
class Outbound:
|
|
716
|
+
__slots__ = ("kind", "to", "reply_to", "body", "expects_reply")
|
|
717
|
+
|
|
718
|
+
def __init__(
|
|
719
|
+
self,
|
|
720
|
+
kind: str,
|
|
721
|
+
to: str | None,
|
|
722
|
+
reply_to: str | None,
|
|
723
|
+
body: str,
|
|
724
|
+
expects_reply: bool = True,
|
|
725
|
+
):
|
|
726
|
+
self.kind, self.to, self.reply_to, self.body = kind, to, reply_to, body
|
|
727
|
+
self.expects_reply = expects_reply
|
|
728
|
+
|
|
729
|
+
|
|
730
|
+
def parse_outbound(text: str) -> tuple[list[Outbound], str, list[str]]:
|
|
731
|
+
"""Extract <swarm-send>/<swarm-broadcast> blocks.
|
|
732
|
+
|
|
733
|
+
Returns the messages, the text with those blocks removed, and a list of
|
|
734
|
+
human-readable problems -- which are fed back to the agent so it can fix its
|
|
735
|
+
own syntax rather than silently losing a message.
|
|
736
|
+
"""
|
|
737
|
+
messages: list[Outbound] = []
|
|
738
|
+
problems: list[str] = []
|
|
739
|
+
matched = 0
|
|
740
|
+
|
|
741
|
+
for match in OUTBOUND_RE.finditer(text or ""):
|
|
742
|
+
matched += 1
|
|
743
|
+
attrs = {k.lower(): v for k, v in ATTR_RE.findall(match.group("attrs"))}
|
|
744
|
+
body = match.group("body").strip()
|
|
745
|
+
if not body:
|
|
746
|
+
problems.append("a message block had an empty body, so there was nothing to send")
|
|
747
|
+
continue
|
|
748
|
+
kind = match.group("kind").lower()
|
|
749
|
+
wants = (attrs.get("expects-reply") or attrs.get("expects_reply") or "true").lower()
|
|
750
|
+
messages.append(
|
|
751
|
+
Outbound(
|
|
752
|
+
kind=kind,
|
|
753
|
+
to=attrs.get("to") or attrs.get("agent"),
|
|
754
|
+
reply_to=attrs.get("reply-to") or attrs.get("reply_to"),
|
|
755
|
+
body=body,
|
|
756
|
+
# A broadcast is an announcement, never a question.
|
|
757
|
+
expects_reply=kind == "send" and wants not in ("false", "no", "0"),
|
|
758
|
+
)
|
|
759
|
+
)
|
|
760
|
+
|
|
761
|
+
remainder = OUTBOUND_RE.sub("", text or "").strip()
|
|
762
|
+
# A delivered block contributes one block-opener too, so more block-openers than
|
|
763
|
+
# delivered blocks means one was opened and never closed. Inline prose mentions
|
|
764
|
+
# of the tag are not block-openers, so naming the tag no longer looks like a
|
|
765
|
+
# failed send.
|
|
766
|
+
openers = sum(
|
|
767
|
+
1 for m in OPENER_RE.finditer(text or "")
|
|
768
|
+
if _is_block_opener(m.group("attrs"), bool(m.group("nl")))
|
|
769
|
+
)
|
|
770
|
+
if openers > matched:
|
|
771
|
+
problems.append(
|
|
772
|
+
"a <swarm-send> block was opened but never closed with </swarm-send>, "
|
|
773
|
+
"so it could not be delivered"
|
|
774
|
+
)
|
|
775
|
+
return messages, remainder, problems
|
|
776
|
+
|
|
777
|
+
|
|
778
|
+
def record_echo(cfg: SwarmConfig, agent: str, text: str) -> None:
|
|
779
|
+
"""Remember text we typed into a pane-captured agent.
|
|
780
|
+
|
|
781
|
+
A terminal echoes whatever is typed into it, so the pane watcher would
|
|
782
|
+
otherwise see an incoming message as if the agent had said it -- and relay
|
|
783
|
+
it straight back out. We keep the delivered lines so the watcher can drop
|
|
784
|
+
them from its diff.
|
|
785
|
+
"""
|
|
786
|
+
path = cfg.run_dir / f"{agent}.echo"
|
|
787
|
+
cfg.run_dir.mkdir(parents=True, exist_ok=True)
|
|
788
|
+
try:
|
|
789
|
+
known = json.loads(path.read_text())
|
|
790
|
+
except (OSError, json.JSONDecodeError):
|
|
791
|
+
known = []
|
|
792
|
+
known.extend(line.strip() for line in text.splitlines() if line.strip())
|
|
793
|
+
path.write_text(json.dumps(known[-ECHO_MEMORY:]))
|
|
794
|
+
|
|
795
|
+
|
|
796
|
+
def read_echo(cfg: SwarmConfig, agent: str) -> set[str]:
|
|
797
|
+
try:
|
|
798
|
+
return set(json.loads((cfg.run_dir / f"{agent}.echo").read_text()))
|
|
799
|
+
except (OSError, json.JSONDecodeError):
|
|
800
|
+
return set()
|
|
801
|
+
|
|
802
|
+
|
|
803
|
+
def read_hops(cfg: SwarmConfig, agent: str) -> int:
|
|
804
|
+
path = cfg.run_dir / f"{agent}.hop"
|
|
805
|
+
try:
|
|
806
|
+
return int(path.read_text().strip())
|
|
807
|
+
except (OSError, ValueError):
|
|
808
|
+
return 0
|
|
809
|
+
|
|
810
|
+
|
|
811
|
+
def write_hops(cfg: SwarmConfig, agent: str, hops: int) -> None:
|
|
812
|
+
cfg.run_dir.mkdir(parents=True, exist_ok=True)
|
|
813
|
+
(cfg.run_dir / f"{agent}.hop").write_text(str(hops))
|
|
814
|
+
|
|
815
|
+
|
|
816
|
+
# --------------------------------------------------------------------------
|
|
817
|
+
# message delivery
|
|
818
|
+
# --------------------------------------------------------------------------
|
|
819
|
+
|
|
820
|
+
|
|
821
|
+
def deliver(
|
|
822
|
+
cfg: SwarmConfig,
|
|
823
|
+
sender: str,
|
|
824
|
+
recipient: str,
|
|
825
|
+
text: str,
|
|
826
|
+
hops: int = 0,
|
|
827
|
+
enforce_acl: bool = True,
|
|
828
|
+
allow_busy: bool = False,
|
|
829
|
+
reply_to: str | None = None,
|
|
830
|
+
expects_reply: bool = True,
|
|
831
|
+
) -> str:
|
|
832
|
+
text = text.strip()
|
|
833
|
+
if not text:
|
|
834
|
+
raise SwarmError("refusing to send an empty message")
|
|
835
|
+
|
|
836
|
+
target = cfg.get(recipient)
|
|
837
|
+
|
|
838
|
+
if enforce_acl and sender in cfg.names():
|
|
839
|
+
source = cfg.get(sender)
|
|
840
|
+
if recipient not in source.can_talk_to:
|
|
841
|
+
allowed = ", ".join(source.can_talk_to) or "no one"
|
|
842
|
+
raise SwarmError(
|
|
843
|
+
f"permission denied: {sender!r} may not message {recipient!r} "
|
|
844
|
+
f"(allowed: {allowed}). Edit can_talk_to in {cfg.path.name} to change this."
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
if not session_exists(target.session):
|
|
848
|
+
raise SwarmError(
|
|
849
|
+
f"agent {recipient!r} is not running (tmux session {target.session!r} missing). "
|
|
850
|
+
"Start it with: swarm up"
|
|
851
|
+
)
|
|
852
|
+
|
|
853
|
+
msg_id = new_message_id()
|
|
854
|
+
body = format_envelope(cfg, sender, recipient, text, msg_id, reply_to)
|
|
855
|
+
# The message id is unique and sits near the top of the envelope, which makes
|
|
856
|
+
# a far better delivery needle than the body tail -- every tagged message ends
|
|
857
|
+
# with the same closing tag.
|
|
858
|
+
needle = normalise(f'id="{msg_id}"') if cfg.message_format == "tagged" else None
|
|
859
|
+
|
|
860
|
+
# Check-and-set under one lock. Two senders -- e.g. two subagents running in
|
|
861
|
+
# parallel -- must not both see an idle agent and both deliver to it, so the
|
|
862
|
+
# busy check and the "now busy" write cannot be separated.
|
|
863
|
+
with pane_lock(cfg, target.session):
|
|
864
|
+
if not allow_busy:
|
|
865
|
+
state = busy_info(cfg, target)
|
|
866
|
+
if state:
|
|
867
|
+
raise BusyError(busy_message(cfg, target, state))
|
|
868
|
+
|
|
869
|
+
if target.capture == "pane":
|
|
870
|
+
record_echo(cfg, recipient, body)
|
|
871
|
+
|
|
872
|
+
if not _paste_locked(cfg, target.session, body, enter=True, needle=needle):
|
|
873
|
+
raise SwarmError(
|
|
874
|
+
f"could not confirm the message reached {recipient!r}; "
|
|
875
|
+
f"inspect it with: swarm attach {recipient}"
|
|
876
|
+
)
|
|
877
|
+
|
|
878
|
+
with file_lock(cfg, recipient, "turn.lock"):
|
|
879
|
+
mark_turn_started(cfg, recipient, sender)
|
|
880
|
+
|
|
881
|
+
archived = archive_message(cfg, sender, recipient, text, msg_id, reply_to)
|
|
882
|
+
# A message that answers another one (reply-to), a broadcast, and an automatic
|
|
883
|
+
# forward are not questions. Only an opening message earns a reply obligation --
|
|
884
|
+
# otherwise an "ACK" would be nagged for an answer to an acknowledgement.
|
|
885
|
+
if expects_reply and not reply_to:
|
|
886
|
+
note_awaiting_reply(cfg, sender, recipient, msg_id)
|
|
887
|
+
write_hops(cfg, recipient, hops)
|
|
888
|
+
log_event(cfg, sender, "sent", to=recipient, hops=hops, id=msg_id, reply_to=reply_to, text=text)
|
|
889
|
+
log_event(
|
|
890
|
+
cfg, recipient, "received", **{"from": sender},
|
|
891
|
+
hops=hops, id=msg_id, reply_to=reply_to, archived=str(archived),
|
|
892
|
+
)
|
|
893
|
+
return msg_id
|
|
894
|
+
|
|
895
|
+
|
|
896
|
+
def route_outbound(cfg: SwarmConfig, agent: Agent, text: str) -> tuple[str, list[str], list[str]]:
|
|
897
|
+
"""Deliver any <swarm-send> blocks the agent wrote.
|
|
898
|
+
|
|
899
|
+
This is the point of tagged messages: the agent writes a multi-line block in
|
|
900
|
+
its reply and never has to shell-quote anything.
|
|
901
|
+
|
|
902
|
+
Returns the leftover text, who was successfully messaged, and what went wrong.
|
|
903
|
+
"""
|
|
904
|
+
if not agent.parse_outbound_tags or not text:
|
|
905
|
+
return text, [], []
|
|
906
|
+
|
|
907
|
+
messages, remainder, problems = parse_outbound(text)
|
|
908
|
+
reached: list[str] = []
|
|
909
|
+
|
|
910
|
+
for message in messages:
|
|
911
|
+
if message.kind == "broadcast":
|
|
912
|
+
targets = agent.can_talk_to
|
|
913
|
+
if not targets:
|
|
914
|
+
problems.append("you used <swarm-broadcast> but you may not message anyone")
|
|
915
|
+
continue
|
|
916
|
+
elif not message.to:
|
|
917
|
+
problems.append(
|
|
918
|
+
'a <swarm-send> was missing its `to` attribute, e.g. <swarm-send to="NAME">'
|
|
919
|
+
)
|
|
920
|
+
continue
|
|
921
|
+
elif message.to not in cfg.names():
|
|
922
|
+
# Most often the agent echoed the AGENT_NAME placeholder from its prompt.
|
|
923
|
+
allowed = ", ".join(agent.can_talk_to) or "no one"
|
|
924
|
+
problems.append(
|
|
925
|
+
f'you addressed <swarm-send to="{message.to}">, which is not an agent. '
|
|
926
|
+
f"You may message: {allowed}"
|
|
927
|
+
)
|
|
928
|
+
continue
|
|
929
|
+
else:
|
|
930
|
+
targets = [message.to]
|
|
931
|
+
|
|
932
|
+
for target in targets:
|
|
933
|
+
try:
|
|
934
|
+
msg_id = deliver(
|
|
935
|
+
cfg, agent.name, target, message.body,
|
|
936
|
+
reply_to=message.reply_to,
|
|
937
|
+
expects_reply=message.expects_reply,
|
|
938
|
+
)
|
|
939
|
+
reached.append(target)
|
|
940
|
+
info(f"{agent.name} -> {target}: routed tagged message {msg_id}")
|
|
941
|
+
except BusyError:
|
|
942
|
+
_, depth = enqueue(
|
|
943
|
+
cfg, agent.name, target, message.body, hops=0,
|
|
944
|
+
reply_to=message.reply_to, expects_reply=message.expects_reply,
|
|
945
|
+
)
|
|
946
|
+
reached.append(target)
|
|
947
|
+
info(f"{agent.name} -> {target}: busy, queued tagged message (depth {depth})")
|
|
948
|
+
except SwarmError as exc:
|
|
949
|
+
reason = str(exc).splitlines()[0]
|
|
950
|
+
problems.append(f"your message to {target} was not delivered: {reason}")
|
|
951
|
+
warn(f"{agent.name} -> {target}: {reason}")
|
|
952
|
+
|
|
953
|
+
return remainder, reached, problems
|
|
954
|
+
|
|
955
|
+
|
|
956
|
+
# --------------------------------------------------------------------------
|
|
957
|
+
# reply reminders
|
|
958
|
+
# --------------------------------------------------------------------------
|
|
959
|
+
#
|
|
960
|
+
# An agent can finish a turn having written its answer as ordinary prose. That
|
|
961
|
+
# prose goes nowhere: only a <swarm-send> block is delivered. The sender would
|
|
962
|
+
# wait forever. So when an agent owes a reply and ends a turn without sending
|
|
963
|
+
# one -- or writes a block we could not deliver -- we tell it, once.
|
|
964
|
+
|
|
965
|
+
SYSTEM_SENDER = "swarm"
|
|
966
|
+
|
|
967
|
+
|
|
968
|
+
def pending_path(cfg: SwarmConfig, agent: str) -> Path:
|
|
969
|
+
return cfg.run_dir / f"{agent}.pending.json"
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def read_pending(cfg: SwarmConfig, agent: str) -> dict | None:
|
|
973
|
+
try:
|
|
974
|
+
return json.loads(pending_path(cfg, agent).read_text())
|
|
975
|
+
except (OSError, json.JSONDecodeError):
|
|
976
|
+
return None
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def write_pending(cfg: SwarmConfig, agent: str, state: dict | None) -> None:
|
|
980
|
+
path = pending_path(cfg, agent)
|
|
981
|
+
if state is None:
|
|
982
|
+
path.unlink(missing_ok=True)
|
|
983
|
+
return
|
|
984
|
+
cfg.run_dir.mkdir(parents=True, exist_ok=True)
|
|
985
|
+
path.write_text(json.dumps(state))
|
|
986
|
+
|
|
987
|
+
|
|
988
|
+
def note_awaiting_reply(cfg: SwarmConfig, sender: str, recipient: str, msg_id: str) -> None:
|
|
989
|
+
"""Remember that *recipient* owes *sender* an answer."""
|
|
990
|
+
target = cfg.get(recipient)
|
|
991
|
+
if not target.reply_reminder or sender not in cfg.names():
|
|
992
|
+
return
|
|
993
|
+
if sender not in target.can_talk_to:
|
|
994
|
+
return # it has no way to answer, so do not badger it
|
|
995
|
+
write_pending(cfg, recipient, {"from": sender, "id": msg_id, "reminders": 0})
|
|
996
|
+
|
|
997
|
+
|
|
998
|
+
def handle_reply_reminder(
|
|
999
|
+
cfg: SwarmConfig, agent: Agent, reached: list[str], problems: list[str]
|
|
1000
|
+
) -> None:
|
|
1001
|
+
if not agent.reply_reminder:
|
|
1002
|
+
return
|
|
1003
|
+
|
|
1004
|
+
pending = read_pending(cfg, agent.name)
|
|
1005
|
+
# `from` is absent when the state was written by a send_failed nudge: nobody is
|
|
1006
|
+
# waiting then, so this must not turn into "someone is waiting for your answer".
|
|
1007
|
+
owes_reply = bool(pending and pending.get("from")) and not reached
|
|
1008
|
+
|
|
1009
|
+
if not problems and not owes_reply:
|
|
1010
|
+
write_pending(cfg, agent.name, None) # it answered; nothing to chase
|
|
1011
|
+
return
|
|
1012
|
+
|
|
1013
|
+
reminders = (pending or {}).get("reminders", 0)
|
|
1014
|
+
if reminders >= cfg.max_reply_reminders:
|
|
1015
|
+
warn(
|
|
1016
|
+
f"{agent.name}: still no valid message after {reminders} reminder(s); "
|
|
1017
|
+
"giving up on this one"
|
|
1018
|
+
)
|
|
1019
|
+
write_pending(cfg, agent.name, None)
|
|
1020
|
+
return
|
|
1021
|
+
|
|
1022
|
+
detail = (
|
|
1023
|
+
"What went wrong:\n" + "\n".join(f" - {p}" for p in problems)
|
|
1024
|
+
if problems
|
|
1025
|
+
else "Your last turn contained no message block at all, so nothing was sent."
|
|
1026
|
+
)
|
|
1027
|
+
peers = ", ".join(agent.can_talk_to) or "no one"
|
|
1028
|
+
|
|
1029
|
+
if owes_reply:
|
|
1030
|
+
template = cfg.reply_reminder_template
|
|
1031
|
+
else:
|
|
1032
|
+
# It tried to send something and we could not deliver it. Correct the syntax
|
|
1033
|
+
# rather than telling it that somebody is waiting -- nobody may be.
|
|
1034
|
+
template = cfg.send_failed_template
|
|
1035
|
+
|
|
1036
|
+
fields = {
|
|
1037
|
+
"agent": agent.name,
|
|
1038
|
+
"sender": (pending or {}).get("from") or "another agent",
|
|
1039
|
+
"id": (pending or {}).get("id") or "",
|
|
1040
|
+
"peers": peers,
|
|
1041
|
+
"problems": detail,
|
|
1042
|
+
}
|
|
1043
|
+
try:
|
|
1044
|
+
text = template.format(**fields).strip()
|
|
1045
|
+
except (KeyError, IndexError, ValueError) as exc:
|
|
1046
|
+
warn(f"{agent.name}: reminder template is malformed ({exc}); using the built-in one")
|
|
1047
|
+
default = (
|
|
1048
|
+
cfgmod.DEFAULT_REPLY_REMINDER_TEMPLATE
|
|
1049
|
+
if owes_reply
|
|
1050
|
+
else cfgmod.DEFAULT_SEND_FAILED_TEMPLATE
|
|
1051
|
+
)
|
|
1052
|
+
text = default.format(**fields).strip()
|
|
1053
|
+
|
|
1054
|
+
try:
|
|
1055
|
+
deliver(cfg, SYSTEM_SENDER, agent.name, text)
|
|
1056
|
+
info(f"{agent.name}: reminded how to send its reply")
|
|
1057
|
+
except BusyError:
|
|
1058
|
+
enqueue(cfg, SYSTEM_SENDER, agent.name, text, hops=0)
|
|
1059
|
+
except SwarmError as exc:
|
|
1060
|
+
warn(f"{agent.name}: could not send the reply reminder: {exc}")
|
|
1061
|
+
return
|
|
1062
|
+
|
|
1063
|
+
state = pending or {"from": None, "id": None}
|
|
1064
|
+
state["reminders"] = reminders + 1
|
|
1065
|
+
write_pending(cfg, agent.name, state)
|
|
1066
|
+
|
|
1067
|
+
|
|
1068
|
+
def on_turn_finished(cfg: SwarmConfig, agent: Agent, text: str) -> None:
|
|
1069
|
+
"""Called whenever a capture tells us the agent completed a turn."""
|
|
1070
|
+
mark_turn_finished(cfg, agent.name)
|
|
1071
|
+
# Route explicit tagged sends first, then auto-forward only what is left, so a
|
|
1072
|
+
# message the agent addressed to one peer is not also broadcast to everybody.
|
|
1073
|
+
remainder, reached, problems = route_outbound(cfg, agent, text)
|
|
1074
|
+
reached += forward_response(cfg, agent, remainder)
|
|
1075
|
+
|
|
1076
|
+
# Hand over waiting mail before nudging. A reminder is itself a message: sending
|
|
1077
|
+
# it first would mark the agent busy and leave the real message stuck in the
|
|
1078
|
+
# queue. If something was delivered, the agent gets another turn, and we
|
|
1079
|
+
# reconsider the reminder when that one ends.
|
|
1080
|
+
if not drain_queue(cfg, agent):
|
|
1081
|
+
handle_reply_reminder(cfg, agent, reached, problems)
|
|
1082
|
+
|
|
1083
|
+
# Any turn end is a chance to rescue mail stranded on an agent whose own capture
|
|
1084
|
+
# never fired -- otherwise a single missed turn-completion wedges its queue.
|
|
1085
|
+
sweep_stale_queues(cfg, exclude=agent.name)
|
|
1086
|
+
|
|
1087
|
+
|
|
1088
|
+
def forward_response(cfg: SwarmConfig, agent: Agent, text: str) -> list[str]:
|
|
1089
|
+
"""Auto-forward a captured turn to the agent's forward_responses_to list.
|
|
1090
|
+
|
|
1091
|
+
Returns who it reached, so an agent that forwards automatically is not then
|
|
1092
|
+
nagged for writing no <swarm-send> block: its words did get delivered.
|
|
1093
|
+
"""
|
|
1094
|
+
if not agent.forward_responses_to or not text.strip():
|
|
1095
|
+
return []
|
|
1096
|
+
|
|
1097
|
+
hops = read_hops(cfg, agent.name) + 1
|
|
1098
|
+
if hops > cfg.max_forward_hops:
|
|
1099
|
+
warn(
|
|
1100
|
+
f"{agent.name}: forward hop limit ({cfg.max_forward_hops}) reached; "
|
|
1101
|
+
"not forwarding further"
|
|
1102
|
+
)
|
|
1103
|
+
log_event(cfg, agent.name, "forward_suppressed", hops=hops)
|
|
1104
|
+
return []
|
|
1105
|
+
|
|
1106
|
+
reached: list[str] = []
|
|
1107
|
+
for peer in agent.forward_responses_to:
|
|
1108
|
+
try:
|
|
1109
|
+
deliver(cfg, agent.name, peer, text, hops=hops, expects_reply=False)
|
|
1110
|
+
reached.append(peer)
|
|
1111
|
+
except BusyError:
|
|
1112
|
+
# An auto-forward must not be dropped just because the peer is mid-task.
|
|
1113
|
+
_, depth = enqueue(cfg, agent.name, peer, text, hops, expects_reply=False)
|
|
1114
|
+
reached.append(peer)
|
|
1115
|
+
info(f"forward {agent.name} -> {peer}: {peer} is busy, queued (depth {depth})")
|
|
1116
|
+
except SwarmError as exc:
|
|
1117
|
+
warn(f"forward {agent.name} -> {peer} failed: {exc}")
|
|
1118
|
+
return reached
|
|
1119
|
+
|
|
1120
|
+
|
|
1121
|
+
# --------------------------------------------------------------------------
|
|
1122
|
+
# capture: hook installation
|
|
1123
|
+
# --------------------------------------------------------------------------
|
|
1124
|
+
|
|
1125
|
+
|
|
1126
|
+
def pretrust_claude_dir(agent: Agent) -> None:
|
|
1127
|
+
"""Mark the agent's workdir as trusted in ~/.claude.json.
|
|
1128
|
+
|
|
1129
|
+
Claude Code asks "Do you trust the files in this folder?" the first time it
|
|
1130
|
+
runs anywhere new -- even under --dangerously-skip-permissions -- and that
|
|
1131
|
+
modal swallows the first prompt (Enter answers the dialog). Codex gets the
|
|
1132
|
+
same treatment via its config.toml; this is the claude equivalent.
|
|
1133
|
+
"""
|
|
1134
|
+
path = Path(os.path.expanduser("~")) / ".claude.json"
|
|
1135
|
+
if not path.is_file():
|
|
1136
|
+
return # claude has never run here; it will create the file itself
|
|
1137
|
+
|
|
1138
|
+
try:
|
|
1139
|
+
data = json.loads(path.read_text())
|
|
1140
|
+
except (OSError, json.JSONDecodeError):
|
|
1141
|
+
warn(f"{agent.name}: could not read ~/.claude.json; the trust dialog may appear")
|
|
1142
|
+
return
|
|
1143
|
+
|
|
1144
|
+
projects = data.setdefault("projects", {})
|
|
1145
|
+
entry = projects.setdefault(str(agent.workdir), {})
|
|
1146
|
+
if entry.get("hasTrustDialogAccepted"):
|
|
1147
|
+
return
|
|
1148
|
+
|
|
1149
|
+
entry["hasTrustDialogAccepted"] = True
|
|
1150
|
+
entry.setdefault("projectOnboardingSeenCount", 1)
|
|
1151
|
+
|
|
1152
|
+
# Write atomically: a running claude may be reading this file.
|
|
1153
|
+
tmp = path.with_suffix(".json.swarm-tmp")
|
|
1154
|
+
try:
|
|
1155
|
+
tmp.write_text(json.dumps(data, indent=2))
|
|
1156
|
+
os.replace(tmp, path)
|
|
1157
|
+
except OSError as exc:
|
|
1158
|
+
warn(f"{agent.name}: could not pre-trust {agent.workdir}: {exc}")
|
|
1159
|
+
tmp.unlink(missing_ok=True)
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
def install_claude_hook(agent: Agent) -> None:
|
|
1163
|
+
pretrust_claude_dir(agent)
|
|
1164
|
+
settings_path = agent.workdir / ".claude" / "settings.json"
|
|
1165
|
+
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
1166
|
+
|
|
1167
|
+
settings: dict = {}
|
|
1168
|
+
if settings_path.is_file():
|
|
1169
|
+
try:
|
|
1170
|
+
settings = json.loads(settings_path.read_text())
|
|
1171
|
+
except json.JSONDecodeError:
|
|
1172
|
+
warn(f"{settings_path} is not valid JSON; overwriting")
|
|
1173
|
+
|
|
1174
|
+
hook_cmd = str(HOOKS_DIR / "claude_stop.sh")
|
|
1175
|
+
# No "matcher" key: Stop is not a tool event, and supplying one can stop the
|
|
1176
|
+
# interactive TUI from ever running the hook.
|
|
1177
|
+
entry = {"hooks": [{"type": "command", "command": hook_cmd}]}
|
|
1178
|
+
hooks = settings.setdefault("hooks", {})
|
|
1179
|
+
stop_hooks = [
|
|
1180
|
+
h
|
|
1181
|
+
for h in hooks.get("Stop", [])
|
|
1182
|
+
if hook_cmd not in json.dumps(h) # drop our own stale entry, keep the user's
|
|
1183
|
+
]
|
|
1184
|
+
stop_hooks.append(entry)
|
|
1185
|
+
hooks["Stop"] = stop_hooks
|
|
1186
|
+
settings_path.write_text(json.dumps(settings, indent=2) + "\n")
|
|
1187
|
+
|
|
1188
|
+
|
|
1189
|
+
def valid_toml(text: str) -> bool:
|
|
1190
|
+
try:
|
|
1191
|
+
import tomllib
|
|
1192
|
+
except ImportError: # Python < 3.11: cannot check, assume the caller is right
|
|
1193
|
+
return True
|
|
1194
|
+
try:
|
|
1195
|
+
tomllib.loads(text)
|
|
1196
|
+
return True
|
|
1197
|
+
except tomllib.TOMLDecodeError:
|
|
1198
|
+
return False
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
def install_codex_hook(agent: Agent) -> Path:
|
|
1202
|
+
"""Give codex a private CODEX_HOME with a `notify` program wired up."""
|
|
1203
|
+
codex_home = agent.workdir / ".codex"
|
|
1204
|
+
codex_home.mkdir(parents=True, exist_ok=True)
|
|
1205
|
+
|
|
1206
|
+
# Carry over the user's real credentials + settings, so the agent is logged in.
|
|
1207
|
+
user_home = Path(os.path.expanduser("~")) / ".codex"
|
|
1208
|
+
base = ""
|
|
1209
|
+
if user_home.is_dir() and user_home.resolve() != codex_home.resolve():
|
|
1210
|
+
for name in ("auth.json",):
|
|
1211
|
+
src, dst = user_home / name, codex_home / name
|
|
1212
|
+
if src.is_file() and not dst.exists():
|
|
1213
|
+
try:
|
|
1214
|
+
dst.symlink_to(src)
|
|
1215
|
+
except OSError:
|
|
1216
|
+
shutil.copy2(src, dst)
|
|
1217
|
+
user_cfg = user_home / "config.toml"
|
|
1218
|
+
if user_cfg.is_file():
|
|
1219
|
+
base = "\n".join(
|
|
1220
|
+
line
|
|
1221
|
+
for line in user_cfg.read_text().splitlines()
|
|
1222
|
+
if not re.match(r"\s*notify\s*=", line)
|
|
1223
|
+
).strip()
|
|
1224
|
+
|
|
1225
|
+
notify = json.dumps(str(HOOKS_DIR / "codex_notify.sh"))
|
|
1226
|
+
# Without this table codex opens a "do you trust this directory?" modal on
|
|
1227
|
+
# first run in a fresh folder, and that modal swallows the first prompt.
|
|
1228
|
+
trust = f"[projects.{json.dumps(str(agent.workdir))}]"
|
|
1229
|
+
|
|
1230
|
+
# TOML is order-sensitive: a bare key written after a [table] header belongs
|
|
1231
|
+
# to that table. `notify` must therefore come before anything else, or codex
|
|
1232
|
+
# reads it as projects.<dir>.notify and never calls it.
|
|
1233
|
+
chunks = [
|
|
1234
|
+
"# installed by AgentSwarm -- fires when codex finishes a turn.",
|
|
1235
|
+
"# Keep `notify` above every [table] header: TOML is order-sensitive.",
|
|
1236
|
+
f"notify = [{notify}]",
|
|
1237
|
+
"",
|
|
1238
|
+
]
|
|
1239
|
+
if base:
|
|
1240
|
+
chunks += [base, ""]
|
|
1241
|
+
if trust not in base: # the user's config may already trust this directory
|
|
1242
|
+
chunks += ["# pre-trust the workdir so no modal eats the first prompt", trust,
|
|
1243
|
+
'trust_level = "trusted"', ""]
|
|
1244
|
+
|
|
1245
|
+
body = "\n".join(chunks)
|
|
1246
|
+
if not valid_toml(body):
|
|
1247
|
+
warn(
|
|
1248
|
+
f"{agent.name}: ~/.codex/config.toml could not be merged cleanly "
|
|
1249
|
+
"(invalid TOML); writing a minimal config instead"
|
|
1250
|
+
)
|
|
1251
|
+
body = "\n".join(
|
|
1252
|
+
[f"notify = [{notify}]", "", trust, 'trust_level = "trusted"', ""]
|
|
1253
|
+
)
|
|
1254
|
+
|
|
1255
|
+
(codex_home / "config.toml").write_text(body)
|
|
1256
|
+
return codex_home
|
|
1257
|
+
|
|
1258
|
+
|
|
1259
|
+
def install_capture(cfg: SwarmConfig, agent: Agent) -> dict[str, str]:
|
|
1260
|
+
"""Install turn-completion capture. Returns extra env vars for the session."""
|
|
1261
|
+
env: dict[str, str] = {}
|
|
1262
|
+
if agent.capture != "hook":
|
|
1263
|
+
return env
|
|
1264
|
+
|
|
1265
|
+
if agent.type == "claude":
|
|
1266
|
+
install_claude_hook(agent)
|
|
1267
|
+
elif agent.type == "codex":
|
|
1268
|
+
env["CODEX_HOME"] = str(install_codex_hook(agent))
|
|
1269
|
+
else:
|
|
1270
|
+
warn(
|
|
1271
|
+
f"agent {agent.name!r}: type {agent.type!r} has no known completion hook "
|
|
1272
|
+
f"(only {', '.join(HOOK_CAPABLE)} do); falling back to capture: pane"
|
|
1273
|
+
)
|
|
1274
|
+
agent.capture = "pane"
|
|
1275
|
+
return env
|
|
1276
|
+
|
|
1277
|
+
|
|
1278
|
+
# --------------------------------------------------------------------------
|
|
1279
|
+
# capture: pane watcher
|
|
1280
|
+
# --------------------------------------------------------------------------
|
|
1281
|
+
|
|
1282
|
+
|
|
1283
|
+
def start_watcher(cfg: SwarmConfig, agent: Agent) -> None:
|
|
1284
|
+
cfg.run_dir.mkdir(parents=True, exist_ok=True)
|
|
1285
|
+
cfg.log_dir.mkdir(parents=True, exist_ok=True)
|
|
1286
|
+
logfile = (cfg.log_dir / f"{agent.name}.watcher.log").open("a")
|
|
1287
|
+
proc = subprocess.Popen(
|
|
1288
|
+
[sys.executable, str(Path(__file__).resolve()), "watch", agent.name, "-c", str(cfg.path)],
|
|
1289
|
+
stdout=logfile,
|
|
1290
|
+
stderr=subprocess.STDOUT,
|
|
1291
|
+
stdin=subprocess.DEVNULL,
|
|
1292
|
+
start_new_session=True,
|
|
1293
|
+
)
|
|
1294
|
+
(cfg.run_dir / f"{agent.name}.watcher.pid").write_text(str(proc.pid))
|
|
1295
|
+
|
|
1296
|
+
|
|
1297
|
+
def stop_watcher(cfg: SwarmConfig, agent: Agent) -> None:
|
|
1298
|
+
pid_file = cfg.run_dir / f"{agent.name}.watcher.pid"
|
|
1299
|
+
if not pid_file.is_file():
|
|
1300
|
+
return
|
|
1301
|
+
try:
|
|
1302
|
+
pid = int(pid_file.read_text().strip())
|
|
1303
|
+
os.kill(pid, 15)
|
|
1304
|
+
except (OSError, ValueError):
|
|
1305
|
+
pass
|
|
1306
|
+
pid_file.unlink(missing_ok=True)
|
|
1307
|
+
|
|
1308
|
+
|
|
1309
|
+
def watcher_alive(cfg: SwarmConfig, agent: Agent) -> bool:
|
|
1310
|
+
pid_file = cfg.run_dir / f"{agent.name}.watcher.pid"
|
|
1311
|
+
if not pid_file.is_file():
|
|
1312
|
+
return False
|
|
1313
|
+
try:
|
|
1314
|
+
os.kill(int(pid_file.read_text().strip()), 0)
|
|
1315
|
+
return True
|
|
1316
|
+
except (OSError, ValueError):
|
|
1317
|
+
return False
|
|
1318
|
+
|
|
1319
|
+
|
|
1320
|
+
READY_TOKEN = "zqxswarmready"
|
|
1321
|
+
|
|
1322
|
+
|
|
1323
|
+
def normalise(text: str) -> str:
|
|
1324
|
+
"""Strip all whitespace, so a TUI's line-wrapping cannot hide a needle."""
|
|
1325
|
+
return re.sub(r"\s+", "", text)
|
|
1326
|
+
|
|
1327
|
+
|
|
1328
|
+
def visible_pane(session: str) -> str:
|
|
1329
|
+
try:
|
|
1330
|
+
return tmux("capture-pane", "-p", "-t", session, capture=True).stdout or ""
|
|
1331
|
+
except subprocess.CalledProcessError:
|
|
1332
|
+
return ""
|
|
1333
|
+
|
|
1334
|
+
|
|
1335
|
+
def clear_token(session: str) -> None:
|
|
1336
|
+
"""Backspace the readiness token back out of the composer."""
|
|
1337
|
+
for _ in range(6):
|
|
1338
|
+
count = normalise(visible_pane(session)).count(READY_TOKEN)
|
|
1339
|
+
if not count:
|
|
1340
|
+
return
|
|
1341
|
+
tmux("send-keys", "-t", session, *(["BSpace"] * (count * len(READY_TOKEN))))
|
|
1342
|
+
sleep_ms(300)
|
|
1343
|
+
|
|
1344
|
+
|
|
1345
|
+
def wait_until_ready(cfg: SwarmConfig, agent: Agent) -> bool:
|
|
1346
|
+
"""Block until the agent's TUI is actually accepting keystrokes.
|
|
1347
|
+
|
|
1348
|
+
A fixed sleep is not enough: Claude Code, for instance, discards input for
|
|
1349
|
+
several seconds partway through its startup, and a prompt typed into that
|
|
1350
|
+
window is silently lost. So we type a throwaway token until the TUI echoes
|
|
1351
|
+
it back -- proof that its input box is live -- then erase it. Nothing is
|
|
1352
|
+
ever submitted, because Enter is never sent.
|
|
1353
|
+
"""
|
|
1354
|
+
deadline = time.monotonic() + cfg.ready_timeout_ms / 1000.0
|
|
1355
|
+
with pane_lock(cfg, agent.session):
|
|
1356
|
+
while time.monotonic() < deadline:
|
|
1357
|
+
tmux("send-keys", "-t", agent.session, "-l", READY_TOKEN, check=False)
|
|
1358
|
+
sleep_ms(600)
|
|
1359
|
+
if READY_TOKEN in normalise(visible_pane(agent.session)):
|
|
1360
|
+
clear_token(agent.session)
|
|
1361
|
+
sleep_ms(cfg.send_delay_ms)
|
|
1362
|
+
return True
|
|
1363
|
+
if not session_exists(agent.session):
|
|
1364
|
+
return False
|
|
1365
|
+
return False
|
|
1366
|
+
|
|
1367
|
+
|
|
1368
|
+
def capture_pane(cfg: SwarmConfig, agent: Agent) -> str:
|
|
1369
|
+
try:
|
|
1370
|
+
result = tmux(
|
|
1371
|
+
"capture-pane", "-p", "-J", "-S", f"-{cfg.pane_scrollback}", "-t", agent.session,
|
|
1372
|
+
capture=True,
|
|
1373
|
+
)
|
|
1374
|
+
except subprocess.CalledProcessError:
|
|
1375
|
+
return ""
|
|
1376
|
+
return result.stdout or ""
|
|
1377
|
+
|
|
1378
|
+
|
|
1379
|
+
def run_watcher(cfg: SwarmConfig, agent: Agent) -> None:
|
|
1380
|
+
"""Poll the pane; when it stops changing, emit whatever text is new.
|
|
1381
|
+
|
|
1382
|
+
This is the fallback for agents whose CLI cannot run a program on turn
|
|
1383
|
+
completion. It is heuristic: it sees rendered terminal output, so spinners
|
|
1384
|
+
and redraws can produce noise.
|
|
1385
|
+
"""
|
|
1386
|
+
info(f"watcher started for {agent.name} (session {agent.session})")
|
|
1387
|
+
emitted: list[str] = capture_pane(cfg, agent).splitlines()
|
|
1388
|
+
previous = list(emitted)
|
|
1389
|
+
last_change = time.monotonic()
|
|
1390
|
+
dirty = False
|
|
1391
|
+
|
|
1392
|
+
while True:
|
|
1393
|
+
sleep_ms(cfg.pane_poll_ms)
|
|
1394
|
+
if not session_exists(agent.session):
|
|
1395
|
+
info(f"watcher for {agent.name}: session gone, exiting")
|
|
1396
|
+
return
|
|
1397
|
+
|
|
1398
|
+
current = capture_pane(cfg, agent).splitlines()
|
|
1399
|
+
if current != previous:
|
|
1400
|
+
previous = current
|
|
1401
|
+
last_change = time.monotonic()
|
|
1402
|
+
dirty = True
|
|
1403
|
+
continue
|
|
1404
|
+
|
|
1405
|
+
idle_for = (time.monotonic() - last_change) * 1000
|
|
1406
|
+
if not dirty or idle_for < cfg.pane_idle_ms:
|
|
1407
|
+
continue
|
|
1408
|
+
dirty = False
|
|
1409
|
+
|
|
1410
|
+
# Emit the tail that appeared since the last quiet moment.
|
|
1411
|
+
common = 0
|
|
1412
|
+
for old, new in zip(emitted, current):
|
|
1413
|
+
if old != new:
|
|
1414
|
+
break
|
|
1415
|
+
common += 1
|
|
1416
|
+
emitted = current
|
|
1417
|
+
|
|
1418
|
+
# Drop the terminal's echo of messages we delivered, so the agent does
|
|
1419
|
+
# not appear to have "said" its own incoming mail.
|
|
1420
|
+
echoed = read_echo(cfg, agent.name)
|
|
1421
|
+
new_lines = [
|
|
1422
|
+
line.rstrip()
|
|
1423
|
+
for line in current[common:]
|
|
1424
|
+
if line.strip()
|
|
1425
|
+
and line.strip() not in echoed
|
|
1426
|
+
and not line.strip().startswith(MESSAGE_HEADER)
|
|
1427
|
+
and not line.strip().startswith((f"<{INBOUND_TAG}", f"</{INBOUND_TAG}"))
|
|
1428
|
+
]
|
|
1429
|
+
|
|
1430
|
+
text = "\n".join(new_lines).strip()
|
|
1431
|
+
if len(text) < 2:
|
|
1432
|
+
continue
|
|
1433
|
+
log_event(cfg, agent.name, "response", source="pane", text=text)
|
|
1434
|
+
on_turn_finished(cfg, agent, text)
|
|
1435
|
+
|
|
1436
|
+
|
|
1437
|
+
# --------------------------------------------------------------------------
|
|
1438
|
+
# hook entry points (called by hooks/*.sh from inside an agent's process)
|
|
1439
|
+
# --------------------------------------------------------------------------
|
|
1440
|
+
|
|
1441
|
+
|
|
1442
|
+
def config_from_state() -> str | None:
|
|
1443
|
+
"""Walk up from the cwd looking for the .swarm/state.json written by `up`."""
|
|
1444
|
+
probe = Path.cwd().resolve()
|
|
1445
|
+
for candidate in [probe, *probe.parents]:
|
|
1446
|
+
state = candidate / ".swarm" / "state.json"
|
|
1447
|
+
if state.is_file():
|
|
1448
|
+
try:
|
|
1449
|
+
return json.loads(state.read_text()).get("config")
|
|
1450
|
+
except (OSError, json.JSONDecodeError):
|
|
1451
|
+
return None
|
|
1452
|
+
return None
|
|
1453
|
+
|
|
1454
|
+
|
|
1455
|
+
def agent_from_cwd(cfg: SwarmConfig) -> str | None:
|
|
1456
|
+
cwd = Path.cwd().resolve()
|
|
1457
|
+
for agent in cfg.agents:
|
|
1458
|
+
workdir = agent.workdir.resolve()
|
|
1459
|
+
if cwd == workdir or workdir in cwd.parents:
|
|
1460
|
+
return agent.name
|
|
1461
|
+
return None
|
|
1462
|
+
|
|
1463
|
+
|
|
1464
|
+
def discover_context(explicit_config: str | None, explicit_agent: str | None):
|
|
1465
|
+
"""Figure out which swarm + agent we are, from argv, env, or the filesystem.
|
|
1466
|
+
|
|
1467
|
+
A hook runs inside the agent's own process, so it may inherit a SWARM_CONFIG
|
|
1468
|
+
(or fall back to an unrelated ./agents.yaml) that does not describe it. Each
|
|
1469
|
+
candidate config is therefore only accepted if it actually contains the
|
|
1470
|
+
calling agent.
|
|
1471
|
+
"""
|
|
1472
|
+
candidates = [explicit_config, os.environ.get("SWARM_CONFIG"), config_from_state()]
|
|
1473
|
+
seen: list[str] = []
|
|
1474
|
+
|
|
1475
|
+
for path in candidates:
|
|
1476
|
+
if not path or not Path(path).is_file() or path in seen:
|
|
1477
|
+
continue
|
|
1478
|
+
seen.append(path)
|
|
1479
|
+
try:
|
|
1480
|
+
cfg = cfgmod.load(path)
|
|
1481
|
+
except ConfigError:
|
|
1482
|
+
continue
|
|
1483
|
+
name = explicit_agent or os.environ.get("SWARM_AGENT") or agent_from_cwd(cfg)
|
|
1484
|
+
if name and name in cfg.names():
|
|
1485
|
+
return cfg, cfg.get(name)
|
|
1486
|
+
|
|
1487
|
+
raise SwarmError(
|
|
1488
|
+
"cannot work out which swarm/agent is calling. Set SWARM_AGENT and SWARM_CONFIG, "
|
|
1489
|
+
"or run from inside an agent's working directory. "
|
|
1490
|
+
f"Configs tried: {', '.join(seen) or 'none'}"
|
|
1491
|
+
)
|
|
1492
|
+
|
|
1493
|
+
|
|
1494
|
+
TRANSCRIPT_WAIT_MS = 5000
|
|
1495
|
+
TRANSCRIPT_POLL_MS = 150
|
|
1496
|
+
|
|
1497
|
+
|
|
1498
|
+
def read_transcript_reply(transcript: str) -> str:
|
|
1499
|
+
"""The agent's reply to the newest user message, or "" if not written yet.
|
|
1500
|
+
|
|
1501
|
+
Two things to be careful about:
|
|
1502
|
+
|
|
1503
|
+
* Subagents (the Task tool) write into the *same* transcript, marked
|
|
1504
|
+
`isSidechain: true`. Their turns are not the agent's answer.
|
|
1505
|
+
* Only text that comes *after* the last user message belongs to this turn.
|
|
1506
|
+
Scanning the whole file would happily return the previous turn's reply.
|
|
1507
|
+
"""
|
|
1508
|
+
records = []
|
|
1509
|
+
try:
|
|
1510
|
+
with open(transcript) as fh:
|
|
1511
|
+
for line in fh:
|
|
1512
|
+
line = line.strip()
|
|
1513
|
+
if not line:
|
|
1514
|
+
continue
|
|
1515
|
+
try:
|
|
1516
|
+
records.append(json.loads(line))
|
|
1517
|
+
except json.JSONDecodeError:
|
|
1518
|
+
continue # a partially flushed final line
|
|
1519
|
+
except OSError:
|
|
1520
|
+
return ""
|
|
1521
|
+
|
|
1522
|
+
last_user = -1
|
|
1523
|
+
for index, record in enumerate(records):
|
|
1524
|
+
if record.get("type") == "user" and not record.get("isSidechain"):
|
|
1525
|
+
last_user = index
|
|
1526
|
+
|
|
1527
|
+
reply = ""
|
|
1528
|
+
for record in records[last_user + 1 :]:
|
|
1529
|
+
if record.get("type") != "assistant" or record.get("isSidechain"):
|
|
1530
|
+
continue
|
|
1531
|
+
content = record.get("message", {}).get("content", [])
|
|
1532
|
+
if isinstance(content, str):
|
|
1533
|
+
reply = content
|
|
1534
|
+
continue
|
|
1535
|
+
chunks = [
|
|
1536
|
+
block.get("text", "")
|
|
1537
|
+
for block in content
|
|
1538
|
+
if isinstance(block, dict) and block.get("type") == "text"
|
|
1539
|
+
]
|
|
1540
|
+
if any(c.strip() for c in chunks):
|
|
1541
|
+
reply = "\n".join(c for c in chunks if c.strip())
|
|
1542
|
+
return reply.strip()
|
|
1543
|
+
|
|
1544
|
+
|
|
1545
|
+
def extract_claude_response(payload: dict) -> str:
|
|
1546
|
+
"""Pull the agent's reply for this turn out of a Claude Code transcript.
|
|
1547
|
+
|
|
1548
|
+
The Stop hook can fire before Claude has flushed the assistant message to
|
|
1549
|
+
disk, so the transcript is polled briefly rather than read once. Without this
|
|
1550
|
+
the hook silently captures nothing -- or, worse, re-reads the previous turn.
|
|
1551
|
+
"""
|
|
1552
|
+
transcript = payload.get("transcript_path")
|
|
1553
|
+
if not transcript or not Path(transcript).is_file():
|
|
1554
|
+
return ""
|
|
1555
|
+
|
|
1556
|
+
deadline = time.monotonic() + TRANSCRIPT_WAIT_MS / 1000.0
|
|
1557
|
+
while True:
|
|
1558
|
+
reply = read_transcript_reply(transcript)
|
|
1559
|
+
if reply or time.monotonic() >= deadline:
|
|
1560
|
+
return reply
|
|
1561
|
+
sleep_ms(TRANSCRIPT_POLL_MS)
|
|
1562
|
+
|
|
1563
|
+
|
|
1564
|
+
def cmd_hook(args) -> int:
|
|
1565
|
+
cfg, agent = discover_context(args.config, args.agent)
|
|
1566
|
+
|
|
1567
|
+
if args.type == "claude":
|
|
1568
|
+
try:
|
|
1569
|
+
payload = json.load(sys.stdin)
|
|
1570
|
+
except json.JSONDecodeError:
|
|
1571
|
+
payload = {}
|
|
1572
|
+
# Claude sets this when a Stop hook already caused a continuation.
|
|
1573
|
+
if payload.get("stop_hook_active"):
|
|
1574
|
+
return 0
|
|
1575
|
+
# Claude hands us its session id on every turn; that is what --resume wants.
|
|
1576
|
+
record_session(
|
|
1577
|
+
cfg, agent, payload.get("session_id"), transcript=payload.get("transcript_path")
|
|
1578
|
+
)
|
|
1579
|
+
text = extract_claude_response(payload)
|
|
1580
|
+
elif args.type == "codex":
|
|
1581
|
+
try:
|
|
1582
|
+
payload = json.loads(args.payload or "{}")
|
|
1583
|
+
except json.JSONDecodeError:
|
|
1584
|
+
payload = {}
|
|
1585
|
+
if payload.get("type") != "agent-turn-complete":
|
|
1586
|
+
return 0
|
|
1587
|
+
session_id, rollout = codex_session(agent)
|
|
1588
|
+
record_session(cfg, agent, session_id, transcript=rollout)
|
|
1589
|
+
text = str(payload.get("last-assistant-message") or "").strip()
|
|
1590
|
+
else:
|
|
1591
|
+
text = sys.stdin.read().strip()
|
|
1592
|
+
|
|
1593
|
+
# An empty turn (tool-only) still ends the turn: mark the agent idle so it can
|
|
1594
|
+
# receive again, even when there is nothing worth logging or relaying.
|
|
1595
|
+
if text:
|
|
1596
|
+
log_event(cfg, agent.name, "response", source=f"hook:{args.type}", text=text)
|
|
1597
|
+
on_turn_finished(cfg, agent, text)
|
|
1598
|
+
return 0
|
|
1599
|
+
|
|
1600
|
+
|
|
1601
|
+
# --------------------------------------------------------------------------
|
|
1602
|
+
# lifecycle
|
|
1603
|
+
# --------------------------------------------------------------------------
|
|
1604
|
+
|
|
1605
|
+
|
|
1606
|
+
def write_shim(cfg: SwarmConfig) -> None:
|
|
1607
|
+
"""A `swarm` executable the agents themselves can call."""
|
|
1608
|
+
cfg.bin_dir.mkdir(parents=True, exist_ok=True)
|
|
1609
|
+
shim = cfg.bin_dir / "swarm"
|
|
1610
|
+
shim.write_text(
|
|
1611
|
+
"#!/usr/bin/env bash\n"
|
|
1612
|
+
"# Generated by AgentSwarm. Lets an agent run `swarm send ...` from its shell.\n"
|
|
1613
|
+
f'exec {shlex.quote(str(SWARM_HOME / "swarm.sh"))} "$@"\n'
|
|
1614
|
+
)
|
|
1615
|
+
shim.chmod(0o755)
|
|
1616
|
+
|
|
1617
|
+
|
|
1618
|
+
def session_env(cfg: SwarmConfig, agent: Agent, extra: dict[str, str]) -> dict[str, str]:
|
|
1619
|
+
env = {
|
|
1620
|
+
"SWARM_HOME": str(SWARM_HOME),
|
|
1621
|
+
"SWARM_CONFIG": str(cfg.path),
|
|
1622
|
+
"SWARM_ROOT": str(cfg.root),
|
|
1623
|
+
"SWARM_NAME": cfg.name,
|
|
1624
|
+
"SWARM_AGENT": agent.name,
|
|
1625
|
+
"SWARM_SESSION": agent.session,
|
|
1626
|
+
"SWARM_PEERS": ",".join(agent.can_talk_to),
|
|
1627
|
+
}
|
|
1628
|
+
env.update(agent.env)
|
|
1629
|
+
env.update(extra)
|
|
1630
|
+
return env
|
|
1631
|
+
|
|
1632
|
+
|
|
1633
|
+
def resume_command(cfg: SwarmConfig, agent: Agent, session_id: str) -> str | None:
|
|
1634
|
+
"""The command that reattaches *agent* to conversation *session_id*.
|
|
1635
|
+
|
|
1636
|
+
`resume_command` wins, because a command like `bash -ic chy3` invokes the CLI
|
|
1637
|
+
through an alias and flags cannot simply be appended to it.
|
|
1638
|
+
"""
|
|
1639
|
+
try:
|
|
1640
|
+
if agent.resume_command:
|
|
1641
|
+
return agent.resume_command.format(session_id=session_id, command=agent.command)
|
|
1642
|
+
if agent.resume_args:
|
|
1643
|
+
return f"{agent.command} {agent.resume_args.format(session_id=session_id)}"
|
|
1644
|
+
except (KeyError, IndexError, ValueError) as exc:
|
|
1645
|
+
warn(f"{agent.name}: resume recipe is malformed ({exc}); starting a fresh conversation")
|
|
1646
|
+
return None
|
|
1647
|
+
|
|
1648
|
+
|
|
1649
|
+
def start_agent(cfg: SwarmConfig, agent: Agent, resume_cmd: str | None = None) -> None:
|
|
1650
|
+
"""Launch the agent. Pass resume_cmd to reattach it to an existing conversation."""
|
|
1651
|
+
resume = resume_cmd is not None
|
|
1652
|
+
if not agent.workdir.is_dir():
|
|
1653
|
+
if not agent.create_workdir:
|
|
1654
|
+
raise SwarmError(
|
|
1655
|
+
f"{agent.name}: workdir does not exist: {agent.workdir} "
|
|
1656
|
+
"(create_workdir is false)"
|
|
1657
|
+
)
|
|
1658
|
+
agent.workdir.mkdir(parents=True, exist_ok=True)
|
|
1659
|
+
info(f"{agent.name}: created {agent.workdir}")
|
|
1660
|
+
|
|
1661
|
+
# No turn is in flight in a newly launched CLI, whether resumed or not. A
|
|
1662
|
+
# resumed agent keeps its unread mail and any reply it still owes.
|
|
1663
|
+
write_turn_state(cfg, agent.name, {"delivered": 0, "completed": 0, "since": 0, "by": None})
|
|
1664
|
+
if not resume:
|
|
1665
|
+
queue_write(cfg, agent.name, [])
|
|
1666
|
+
write_pending(cfg, agent.name, None)
|
|
1667
|
+
|
|
1668
|
+
extra_env = install_capture(cfg, agent)
|
|
1669
|
+
env = session_env(cfg, agent, extra_env)
|
|
1670
|
+
|
|
1671
|
+
exports = " ".join(f"export {k}={shlex.quote(v)};" for k, v in env.items())
|
|
1672
|
+
exports += f' export PATH={shlex.quote(str(cfg.bin_dir))}:"$PATH";'
|
|
1673
|
+
|
|
1674
|
+
command = resume_cmd or agent.command
|
|
1675
|
+
|
|
1676
|
+
inner = (
|
|
1677
|
+
f"{exports} "
|
|
1678
|
+
f"cd {shlex.quote(str(agent.workdir))} || exit 1; "
|
|
1679
|
+
f"{command}; "
|
|
1680
|
+
f'status=$?; printf "\\n[swarm] agent %s exited (status %s)\\n" '
|
|
1681
|
+
f"{shlex.quote(agent.name)} \"$status\"; "
|
|
1682
|
+
'exec "${SHELL:-bash}" -l'
|
|
1683
|
+
)
|
|
1684
|
+
launcher = f"exec bash -lc {shlex.quote(inner)}"
|
|
1685
|
+
|
|
1686
|
+
# -x/-y give the detached pane a real size, so a long turn's output does not
|
|
1687
|
+
# wrap to an 80x24 default and lose lines. history-limit and mouse are read
|
|
1688
|
+
# from the globals configure_tmux() set *before* this call, so the pane
|
|
1689
|
+
# inherits the large scrollback the user needs to scroll back through.
|
|
1690
|
+
tmux("new-session", "-d", "-s", agent.session, "-x", "220", "-y", "50",
|
|
1691
|
+
"-c", str(agent.workdir), launcher)
|
|
1692
|
+
info(f"started {agent.name} ({agent.type}) in tmux session {agent.session!r}")
|
|
1693
|
+
|
|
1694
|
+
|
|
1695
|
+
def cmd_up(args) -> int:
|
|
1696
|
+
cfg = cfgmod.load(args.config)
|
|
1697
|
+
if not shutil.which("tmux"):
|
|
1698
|
+
die("tmux is required but was not found on PATH")
|
|
1699
|
+
|
|
1700
|
+
selected = select_agents(cfg, args.only)
|
|
1701
|
+
for message in cfg.warnings:
|
|
1702
|
+
warn(message)
|
|
1703
|
+
|
|
1704
|
+
for directory in (cfg.runtime, cfg.log_dir, cfg.inbox_dir, cfg.run_dir, cfg.bin_dir):
|
|
1705
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
1706
|
+
write_shim(cfg)
|
|
1707
|
+
write_state(cfg)
|
|
1708
|
+
setup_holder = configure_tmux(cfg)
|
|
1709
|
+
|
|
1710
|
+
resume = cfg.resume if args.resume is None else args.resume
|
|
1711
|
+
recorded = read_sessions(cfg) if resume else {}
|
|
1712
|
+
|
|
1713
|
+
started: list[Agent] = []
|
|
1714
|
+
resumed: set[str] = set()
|
|
1715
|
+
for agent in selected:
|
|
1716
|
+
if session_exists(agent.session):
|
|
1717
|
+
if not args.restart:
|
|
1718
|
+
warn(f"{agent.name}: session {agent.session!r} already exists, skipping")
|
|
1719
|
+
continue
|
|
1720
|
+
info(f"{agent.name}: restarting")
|
|
1721
|
+
stop_watcher(cfg, agent)
|
|
1722
|
+
tmux("kill-session", "-t", f"={agent.session}", check=False, capture=True)
|
|
1723
|
+
|
|
1724
|
+
resume_cmd = None
|
|
1725
|
+
if resume:
|
|
1726
|
+
session_id = (recorded.get(agent.name) or {}).get("session_id")
|
|
1727
|
+
if not session_id:
|
|
1728
|
+
warn(f"{agent.name}: no recorded conversation; starting a fresh one")
|
|
1729
|
+
else:
|
|
1730
|
+
resume_cmd = resume_command(cfg, agent, session_id)
|
|
1731
|
+
if resume_cmd:
|
|
1732
|
+
resumed.add(agent.name)
|
|
1733
|
+
info(f"{agent.name}: resuming conversation {session_id}")
|
|
1734
|
+
else:
|
|
1735
|
+
warn(
|
|
1736
|
+
f"{agent.name}: type {agent.type!r} has no resume recipe "
|
|
1737
|
+
"(set resume_args or resume_command); starting a fresh conversation"
|
|
1738
|
+
)
|
|
1739
|
+
|
|
1740
|
+
start_agent(cfg, agent, resume_cmd)
|
|
1741
|
+
started.append(agent)
|
|
1742
|
+
|
|
1743
|
+
# The real agent sessions now keep the server alive, so the throwaway holder
|
|
1744
|
+
# that let us set the global scrollback has done its job.
|
|
1745
|
+
if setup_holder:
|
|
1746
|
+
tmux("kill-session", "-t", f"={setup_holder}", check=False, capture=True)
|
|
1747
|
+
|
|
1748
|
+
if not started:
|
|
1749
|
+
info("nothing to start")
|
|
1750
|
+
return 0
|
|
1751
|
+
|
|
1752
|
+
# An agent launched into a brand-new conversation must not keep claiming the
|
|
1753
|
+
# old one, or a later `up --resume` would reattach to a session it never ran.
|
|
1754
|
+
fresh = [a.name for a in started if a.name not in resumed]
|
|
1755
|
+
if fresh:
|
|
1756
|
+
with file_lock(cfg, "sessions", "lock"):
|
|
1757
|
+
recorded_now = read_sessions(cfg)
|
|
1758
|
+
if any(name in recorded_now for name in fresh):
|
|
1759
|
+
for name in fresh:
|
|
1760
|
+
recorded_now.pop(name, None)
|
|
1761
|
+
write_sessions(cfg, recorded_now)
|
|
1762
|
+
|
|
1763
|
+
if args.no_prompt:
|
|
1764
|
+
info("skipping first prompts (--no-prompt)")
|
|
1765
|
+
else:
|
|
1766
|
+
# Give the CLIs a moment to draw their splash, then wait for each one's
|
|
1767
|
+
# input box to actually respond before typing a prompt into it.
|
|
1768
|
+
boot = max(a.boot_delay_ms for a in started)
|
|
1769
|
+
info(f"waiting {boot}ms for agents to boot...")
|
|
1770
|
+
sleep_ms(boot)
|
|
1771
|
+
|
|
1772
|
+
for agent in started:
|
|
1773
|
+
if agent.name in resumed:
|
|
1774
|
+
# It already has the prompt, and the whole conversation after it.
|
|
1775
|
+
info(f"{agent.name}: resumed, not re-sending the first prompt")
|
|
1776
|
+
continue
|
|
1777
|
+
if not agent.first_prompt:
|
|
1778
|
+
continue
|
|
1779
|
+
try:
|
|
1780
|
+
if agent.ready_probe and not wait_until_ready(cfg, agent):
|
|
1781
|
+
warn(
|
|
1782
|
+
f"{agent.name}: input box never responded within "
|
|
1783
|
+
f"{cfg.ready_timeout_ms}ms; sending the prompt anyway"
|
|
1784
|
+
)
|
|
1785
|
+
if paste_into(cfg, agent.session, agent.first_prompt):
|
|
1786
|
+
# The agent is now working on it, so it counts as a started turn.
|
|
1787
|
+
with file_lock(cfg, agent.name, "turn.lock"):
|
|
1788
|
+
mark_turn_started(cfg, agent.name, "user")
|
|
1789
|
+
info(f"sent first prompt to {agent.name}")
|
|
1790
|
+
else:
|
|
1791
|
+
warn(f"{agent.name}: first prompt may not have been delivered")
|
|
1792
|
+
log_event(cfg, agent.name, "first_prompt", text=agent.first_prompt)
|
|
1793
|
+
except SwarmError as exc:
|
|
1794
|
+
warn(f"{agent.name}: could not send first prompt: {exc}")
|
|
1795
|
+
sleep_ms(cfg.send_delay_ms)
|
|
1796
|
+
|
|
1797
|
+
# Watchers start last, so they do not mistake a boot banner or the readiness
|
|
1798
|
+
# probe for something the agent said.
|
|
1799
|
+
for agent in started:
|
|
1800
|
+
if agent.capture == "pane":
|
|
1801
|
+
start_watcher(cfg, agent)
|
|
1802
|
+
|
|
1803
|
+
print()
|
|
1804
|
+
info(f"swarm {cfg.name!r} is up with {len(started)} agent(s)")
|
|
1805
|
+
info(f"attach with: tmux attach -t {started[0].session}")
|
|
1806
|
+
info(f"or: {SWARM_HOME / 'swarm.sh'} attach {started[0].name}")
|
|
1807
|
+
|
|
1808
|
+
if args.attach:
|
|
1809
|
+
os.execvp("tmux", ["tmux", "attach", "-t", started[0].session])
|
|
1810
|
+
return 0
|
|
1811
|
+
|
|
1812
|
+
|
|
1813
|
+
def cmd_down(args) -> int:
|
|
1814
|
+
cfg = cfgmod.load(args.config)
|
|
1815
|
+
for agent in select_agents(cfg, args.only):
|
|
1816
|
+
stop_watcher(cfg, agent)
|
|
1817
|
+
if session_exists(agent.session):
|
|
1818
|
+
tmux("kill-session", "-t", f"={agent.session}", check=False, capture=True)
|
|
1819
|
+
info(f"stopped {agent.name}")
|
|
1820
|
+
else:
|
|
1821
|
+
info(f"{agent.name}: not running")
|
|
1822
|
+
return 0
|
|
1823
|
+
|
|
1824
|
+
|
|
1825
|
+
def cmd_restart(args) -> int:
|
|
1826
|
+
cmd_down(args)
|
|
1827
|
+
args.restart = True
|
|
1828
|
+
return cmd_up(args)
|
|
1829
|
+
|
|
1830
|
+
|
|
1831
|
+
def cmd_status(args) -> int:
|
|
1832
|
+
cfg = cfgmod.load(args.config)
|
|
1833
|
+
rows = []
|
|
1834
|
+
for agent in cfg.agents:
|
|
1835
|
+
running = session_exists(agent.session)
|
|
1836
|
+
capture = agent.capture
|
|
1837
|
+
if capture == "pane":
|
|
1838
|
+
capture += " (watching)" if watcher_alive(cfg, agent) else " (watcher down)"
|
|
1839
|
+
|
|
1840
|
+
if not running:
|
|
1841
|
+
turn = "-"
|
|
1842
|
+
elif not agent.busy_check:
|
|
1843
|
+
turn = "untracked"
|
|
1844
|
+
else:
|
|
1845
|
+
state = busy_info(cfg, agent)
|
|
1846
|
+
turn = f"busy {state['age_s']}s" if state else "idle"
|
|
1847
|
+
|
|
1848
|
+
depth = len(queue_read(cfg, agent.name))
|
|
1849
|
+
rows.append(
|
|
1850
|
+
(
|
|
1851
|
+
agent.name,
|
|
1852
|
+
agent.type,
|
|
1853
|
+
"up" if running else "down",
|
|
1854
|
+
turn,
|
|
1855
|
+
str(depth) if depth else "-",
|
|
1856
|
+
capture,
|
|
1857
|
+
", ".join(agent.can_talk_to) or "-",
|
|
1858
|
+
)
|
|
1859
|
+
)
|
|
1860
|
+
|
|
1861
|
+
headers = ("AGENT", "TYPE", "STATE", "TURN", "QUEUE", "CAPTURE", "CAN TALK TO")
|
|
1862
|
+
widths = [max(len(str(r[i])) for r in (*rows, headers)) for i in range(len(headers))]
|
|
1863
|
+
line = " ".join(h.ljust(widths[i]) for i, h in enumerate(headers))
|
|
1864
|
+
print(f"swarm: {cfg.name} root: {cfg.root}")
|
|
1865
|
+
print(line)
|
|
1866
|
+
print(" ".join("-" * w for w in widths))
|
|
1867
|
+
for row in rows:
|
|
1868
|
+
colour = "\033[32m" if row[2] == "up" else "\033[31m"
|
|
1869
|
+
cells = [str(c).ljust(widths[i]) for i, c in enumerate(row)]
|
|
1870
|
+
cells[2] = f"{colour}{cells[2]}\033[0m"
|
|
1871
|
+
print(" ".join(cells))
|
|
1872
|
+
return 0
|
|
1873
|
+
|
|
1874
|
+
|
|
1875
|
+
def cmd_queue(args) -> int:
|
|
1876
|
+
cfg = cfgmod.load(args.config)
|
|
1877
|
+
agent = cfg.get(args.agent)
|
|
1878
|
+
|
|
1879
|
+
if args.clear:
|
|
1880
|
+
with file_lock(cfg, agent.name, "queue.lock"):
|
|
1881
|
+
dropped = len(queue_read(cfg, agent.name))
|
|
1882
|
+
queue_write(cfg, agent.name, [])
|
|
1883
|
+
info(f"{agent.name}: dropped {dropped} queued message(s)")
|
|
1884
|
+
return 0
|
|
1885
|
+
|
|
1886
|
+
items = queue_read(cfg, agent.name)
|
|
1887
|
+
state = busy_info(cfg, agent)
|
|
1888
|
+
status = f"busy for {state['age_s']}s (task from {state['by']})" if state else "idle"
|
|
1889
|
+
print(f"{agent.name}: {status}, {len(items)} message(s) queued")
|
|
1890
|
+
for index, item in enumerate(items, 1):
|
|
1891
|
+
first = item["text"].strip().splitlines()[0][:70]
|
|
1892
|
+
print(f" {index}. from {item['from']} at {item['ts']}: {first}")
|
|
1893
|
+
return 0
|
|
1894
|
+
|
|
1895
|
+
|
|
1896
|
+
def cmd_idle(args) -> int:
|
|
1897
|
+
"""Force an agent back to idle -- the escape hatch when a capture never fired."""
|
|
1898
|
+
cfg = cfgmod.load(args.config)
|
|
1899
|
+
agent = cfg.get(args.agent)
|
|
1900
|
+
mark_turn_finished(cfg, agent.name)
|
|
1901
|
+
info(f"{agent.name}: marked idle")
|
|
1902
|
+
if not args.no_drain:
|
|
1903
|
+
drain_queue(cfg, agent)
|
|
1904
|
+
return 0
|
|
1905
|
+
|
|
1906
|
+
|
|
1907
|
+
def cmd_sessions(args) -> int:
|
|
1908
|
+
cfg = cfgmod.load(args.config)
|
|
1909
|
+
agents = read_sessions(cfg)
|
|
1910
|
+
if not agents:
|
|
1911
|
+
print(f"no conversations recorded yet ({cfg.sessions_file})")
|
|
1912
|
+
print("They are written as each agent finishes its first turn.")
|
|
1913
|
+
return 0
|
|
1914
|
+
|
|
1915
|
+
if args.raw:
|
|
1916
|
+
print(cfg.sessions_file.read_text().rstrip())
|
|
1917
|
+
return 0
|
|
1918
|
+
|
|
1919
|
+
print(f"{cfg.sessions_file}\n")
|
|
1920
|
+
for name in cfg.names():
|
|
1921
|
+
entry = agents.get(name)
|
|
1922
|
+
if not entry:
|
|
1923
|
+
print(f" {name}: -")
|
|
1924
|
+
continue
|
|
1925
|
+
print(f" {name} ({entry.get('type')})")
|
|
1926
|
+
print(f" conversation: {entry.get('session_id')}")
|
|
1927
|
+
print(f" last seen: {entry.get('updated_at')}")
|
|
1928
|
+
resumable = resume_command(cfg, cfg.get(name), str(entry.get("session_id")))
|
|
1929
|
+
print(f" resume with: {resumable or '(no resume recipe for this type)'}")
|
|
1930
|
+
return 0
|
|
1931
|
+
|
|
1932
|
+
|
|
1933
|
+
def cmd_attach(args) -> int:
|
|
1934
|
+
cfg = cfgmod.load(args.config)
|
|
1935
|
+
agent = cfg.get(args.agent)
|
|
1936
|
+
if not session_exists(agent.session):
|
|
1937
|
+
die(f"{agent.name} is not running")
|
|
1938
|
+
os.execvp("tmux", ["tmux", "attach", "-t", agent.session])
|
|
1939
|
+
return 0
|
|
1940
|
+
|
|
1941
|
+
|
|
1942
|
+
def read_message(args) -> str:
|
|
1943
|
+
if args.file:
|
|
1944
|
+
return Path(args.file).read_text()
|
|
1945
|
+
if args.message == ["-"] or not args.message:
|
|
1946
|
+
if sys.stdin.isatty():
|
|
1947
|
+
die("no message given (pass it as an argument, with --file, or on stdin)")
|
|
1948
|
+
return sys.stdin.read()
|
|
1949
|
+
return " ".join(args.message)
|
|
1950
|
+
|
|
1951
|
+
|
|
1952
|
+
def resolve_sender(cfg: SwarmConfig, explicit: str | None) -> str:
|
|
1953
|
+
return explicit or os.environ.get("SWARM_AGENT") or "user"
|
|
1954
|
+
|
|
1955
|
+
|
|
1956
|
+
def wait_for_dequeue(cfg: SwarmConfig, recipient: str, item_id: str, timeout_s: float) -> bool:
|
|
1957
|
+
"""Block until a queued message has been handed to the agent."""
|
|
1958
|
+
deadline = time.monotonic() + timeout_s
|
|
1959
|
+
while time.monotonic() < deadline:
|
|
1960
|
+
if not any(i["id"] == item_id for i in queue_read(cfg, recipient)):
|
|
1961
|
+
return True
|
|
1962
|
+
time.sleep(2)
|
|
1963
|
+
return False
|
|
1964
|
+
|
|
1965
|
+
|
|
1966
|
+
def send_message(cfg: SwarmConfig, sender: str, args, text: str) -> int:
|
|
1967
|
+
recipient = args.to
|
|
1968
|
+
allow_busy = args.force or args.ignore_busy
|
|
1969
|
+
deadline = time.monotonic() + args.wait_timeout
|
|
1970
|
+
|
|
1971
|
+
while True:
|
|
1972
|
+
try:
|
|
1973
|
+
deliver(cfg, sender, recipient, text, enforce_acl=not args.force, allow_busy=allow_busy)
|
|
1974
|
+
info(f"{sender} -> {recipient}: delivered")
|
|
1975
|
+
return 0
|
|
1976
|
+
except BusyError as exc:
|
|
1977
|
+
if args.queue:
|
|
1978
|
+
item_id, depth = enqueue(cfg, sender, recipient, text, hops=0)
|
|
1979
|
+
info(
|
|
1980
|
+
f"{recipient} is busy; queued at position {depth}. "
|
|
1981
|
+
f"It will be delivered as soon as {recipient} is free."
|
|
1982
|
+
)
|
|
1983
|
+
if not args.wait:
|
|
1984
|
+
return 0
|
|
1985
|
+
info(f"waiting for {recipient} to pick it up...")
|
|
1986
|
+
if wait_for_dequeue(cfg, recipient, item_id, deadline - time.monotonic()):
|
|
1987
|
+
info(f"{recipient} received your queued message")
|
|
1988
|
+
return 0
|
|
1989
|
+
warn(f"still queued after {args.wait_timeout}s; it stays in the queue")
|
|
1990
|
+
return 0
|
|
1991
|
+
|
|
1992
|
+
if args.wait:
|
|
1993
|
+
if time.monotonic() >= deadline:
|
|
1994
|
+
die(f"{recipient} was still busy after {args.wait_timeout}s")
|
|
1995
|
+
time.sleep(3)
|
|
1996
|
+
continue
|
|
1997
|
+
|
|
1998
|
+
die(str(exc))
|
|
1999
|
+
|
|
2000
|
+
|
|
2001
|
+
def cmd_send(args) -> int:
|
|
2002
|
+
cfg = cfgmod.load(args.config)
|
|
2003
|
+
sender = resolve_sender(cfg, args.sender)
|
|
2004
|
+
return send_message(cfg, sender, args, read_message(args))
|
|
2005
|
+
|
|
2006
|
+
|
|
2007
|
+
def cmd_broadcast(args) -> int:
|
|
2008
|
+
cfg = cfgmod.load(args.config)
|
|
2009
|
+
sender = resolve_sender(cfg, args.sender)
|
|
2010
|
+
text = read_message(args)
|
|
2011
|
+
|
|
2012
|
+
if sender in cfg.names():
|
|
2013
|
+
targets = cfg.get(sender).can_talk_to
|
|
2014
|
+
else:
|
|
2015
|
+
targets = [a.name for a in cfg.agents]
|
|
2016
|
+
if not targets:
|
|
2017
|
+
die(f"{sender} has no agents it may talk to")
|
|
2018
|
+
|
|
2019
|
+
failures = 0
|
|
2020
|
+
for peer in targets:
|
|
2021
|
+
try:
|
|
2022
|
+
deliver(
|
|
2023
|
+
cfg, sender, peer, text,
|
|
2024
|
+
enforce_acl=not args.force,
|
|
2025
|
+
allow_busy=args.force or args.ignore_busy,
|
|
2026
|
+
)
|
|
2027
|
+
info(f"{sender} -> {peer}: delivered")
|
|
2028
|
+
except BusyError as exc:
|
|
2029
|
+
if args.queue:
|
|
2030
|
+
_, depth = enqueue(cfg, sender, peer, text, hops=0)
|
|
2031
|
+
info(f"{sender} -> {peer}: busy, queued at position {depth}")
|
|
2032
|
+
else:
|
|
2033
|
+
warn(f"{sender} -> {peer}: {exc.args[0].splitlines()[0]}")
|
|
2034
|
+
failures += 1
|
|
2035
|
+
except SwarmError as exc:
|
|
2036
|
+
warn(f"{sender} -> {peer}: {exc}")
|
|
2037
|
+
failures += 1
|
|
2038
|
+
return 1 if failures else 0
|
|
2039
|
+
|
|
2040
|
+
|
|
2041
|
+
def cmd_inbox(args) -> int:
|
|
2042
|
+
cfg = cfgmod.load(args.config)
|
|
2043
|
+
name = args.agent or os.environ.get("SWARM_AGENT")
|
|
2044
|
+
if not name:
|
|
2045
|
+
die("specify an agent: swarm inbox <agent>")
|
|
2046
|
+
cfg.get(name)
|
|
2047
|
+
|
|
2048
|
+
box = cfg.inbox_dir / name
|
|
2049
|
+
messages = sorted(box.glob("*.md")) if box.is_dir() else []
|
|
2050
|
+
if not messages:
|
|
2051
|
+
print(f"{name}: inbox is empty")
|
|
2052
|
+
return 0
|
|
2053
|
+
for path in messages[-args.tail :]:
|
|
2054
|
+
print(f"\n\033[1m--- {path.name} ---\033[0m")
|
|
2055
|
+
print(path.read_text().rstrip())
|
|
2056
|
+
return 0
|
|
2057
|
+
|
|
2058
|
+
|
|
2059
|
+
def cmd_logs(args) -> int:
|
|
2060
|
+
cfg = cfgmod.load(args.config)
|
|
2061
|
+
name = args.agent or "swarm"
|
|
2062
|
+
if name != "swarm":
|
|
2063
|
+
cfg.get(name)
|
|
2064
|
+
path = cfg.log_dir / f"{name}.jsonl"
|
|
2065
|
+
if not path.is_file():
|
|
2066
|
+
print(f"no log yet at {path}")
|
|
2067
|
+
return 0
|
|
2068
|
+
if args.follow:
|
|
2069
|
+
os.execvp("tail", ["tail", "-f", str(path)])
|
|
2070
|
+
|
|
2071
|
+
lines = path.read_text().splitlines()[-args.tail :]
|
|
2072
|
+
for line in lines:
|
|
2073
|
+
try:
|
|
2074
|
+
rec = json.loads(line)
|
|
2075
|
+
except json.JSONDecodeError:
|
|
2076
|
+
continue
|
|
2077
|
+
text = (rec.get("text") or "").strip().replace("\n", "\n ")
|
|
2078
|
+
detail = rec.get("to") or rec.get("from") or rec.get("source") or ""
|
|
2079
|
+
print(f"\033[2m{rec['ts']}\033[0m \033[1m{rec['agent']}\033[0m {rec['kind']} {detail}")
|
|
2080
|
+
if text:
|
|
2081
|
+
print(f" {text}")
|
|
2082
|
+
return 0
|
|
2083
|
+
|
|
2084
|
+
|
|
2085
|
+
def cmd_validate(args) -> int:
|
|
2086
|
+
cfg = cfgmod.load(args.config)
|
|
2087
|
+
for message in cfg.warnings:
|
|
2088
|
+
warn(message)
|
|
2089
|
+
print(f"config ok: {cfg.path}")
|
|
2090
|
+
print(f" swarm: {cfg.name}")
|
|
2091
|
+
print(f" root: {cfg.root}")
|
|
2092
|
+
print(f" agents: {len(cfg.agents)}")
|
|
2093
|
+
for agent in cfg.agents:
|
|
2094
|
+
peers = ", ".join(agent.can_talk_to) or "none"
|
|
2095
|
+
fwd = ", ".join(agent.forward_responses_to)
|
|
2096
|
+
if agent.workdir.is_dir():
|
|
2097
|
+
state = "exists"
|
|
2098
|
+
else:
|
|
2099
|
+
state = "will be created" if agent.create_workdir else "MISSING"
|
|
2100
|
+
print(f"\n - {agent.name} ({agent.type}, capture={agent.capture})")
|
|
2101
|
+
print(f" command: {agent.command}")
|
|
2102
|
+
print(f" workdir: {agent.workdir} [{state}]")
|
|
2103
|
+
print(f" session: {agent.session}")
|
|
2104
|
+
print(f" talks to: {peers}")
|
|
2105
|
+
if fwd:
|
|
2106
|
+
print(f" auto-forwards responses to: {fwd}")
|
|
2107
|
+
if args.show_prompts and agent.first_prompt:
|
|
2108
|
+
body = "\n".join(f" | {l}" for l in agent.first_prompt.splitlines())
|
|
2109
|
+
print(f" first prompt:\n{body}")
|
|
2110
|
+
return 0
|
|
2111
|
+
|
|
2112
|
+
|
|
2113
|
+
def cmd_watch(args) -> int:
|
|
2114
|
+
cfg = cfgmod.load(args.config)
|
|
2115
|
+
run_watcher(cfg, cfg.get(args.agent))
|
|
2116
|
+
return 0
|
|
2117
|
+
|
|
2118
|
+
|
|
2119
|
+
def select_agents(cfg: SwarmConfig, only: str | None) -> list[Agent]:
|
|
2120
|
+
if not only:
|
|
2121
|
+
return list(cfg.agents)
|
|
2122
|
+
names = [n.strip() for n in only.split(",") if n.strip()]
|
|
2123
|
+
return [cfg.get(n) for n in names]
|
|
2124
|
+
|
|
2125
|
+
|
|
2126
|
+
# --------------------------------------------------------------------------
|
|
2127
|
+
# CLI
|
|
2128
|
+
# --------------------------------------------------------------------------
|
|
2129
|
+
|
|
2130
|
+
|
|
2131
|
+
def default_config() -> str:
|
|
2132
|
+
"""SWARM_CONFIG, else ./agents.yaml, else the agents.yaml beside swarm.sh."""
|
|
2133
|
+
from_env = os.environ.get("SWARM_CONFIG")
|
|
2134
|
+
if from_env:
|
|
2135
|
+
return from_env
|
|
2136
|
+
for candidate in (Path.cwd() / "agents.yaml", SWARM_HOME / "agents.yaml"):
|
|
2137
|
+
if candidate.is_file():
|
|
2138
|
+
return str(candidate)
|
|
2139
|
+
return "agents.yaml"
|
|
2140
|
+
|
|
2141
|
+
|
|
2142
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
2143
|
+
parser = argparse.ArgumentParser(
|
|
2144
|
+
prog=os.environ.get("SWARM_PROG", "agentainer"),
|
|
2145
|
+
description="Run a swarm of coding agents (claude, codex, gemini, hermes) in tmux.",
|
|
2146
|
+
)
|
|
2147
|
+
parser.add_argument(
|
|
2148
|
+
"-c", "--config", default=default_config(), help="path to the swarm YAML (default: agents.yaml)"
|
|
2149
|
+
)
|
|
2150
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
2151
|
+
|
|
2152
|
+
def add(name, func, help_text):
|
|
2153
|
+
p = sub.add_parser(name, help=help_text)
|
|
2154
|
+
p.set_defaults(func=func)
|
|
2155
|
+
# SUPPRESS (not a real default) so that omitting -c here leaves the
|
|
2156
|
+
# value parsed from the top-level -c intact instead of overwriting it.
|
|
2157
|
+
p.add_argument("-c", "--config", default=argparse.SUPPRESS, help=argparse.SUPPRESS)
|
|
2158
|
+
return p
|
|
2159
|
+
|
|
2160
|
+
p_up = add("up", cmd_up, "create agent dirs, install hooks, start tmux sessions, send prompts")
|
|
2161
|
+
p_up.add_argument("--only", help="comma-separated subset of agents to start")
|
|
2162
|
+
p_up.add_argument(
|
|
2163
|
+
"--resume", dest="resume", action="store_true", default=None,
|
|
2164
|
+
help="reattach each agent to the conversation recorded in sessions.yaml",
|
|
2165
|
+
)
|
|
2166
|
+
p_up.add_argument(
|
|
2167
|
+
"--no-resume", dest="resume", action="store_false",
|
|
2168
|
+
help="start fresh conversations even if swarm.resume is true",
|
|
2169
|
+
)
|
|
2170
|
+
p_up.add_argument("--restart", action="store_true", help="kill and recreate existing sessions")
|
|
2171
|
+
p_up.add_argument("--no-prompt", action="store_true", help="start agents without sending first prompts")
|
|
2172
|
+
p_up.add_argument("--attach", action="store_true", help="attach to the first agent once started")
|
|
2173
|
+
|
|
2174
|
+
p_down = add("down", cmd_down, "kill agent tmux sessions and watchers")
|
|
2175
|
+
p_down.add_argument("--only", help="comma-separated subset of agents to stop")
|
|
2176
|
+
|
|
2177
|
+
p_restart = add("restart", cmd_restart, "down + up")
|
|
2178
|
+
p_restart.add_argument("--only", help="comma-separated subset of agents")
|
|
2179
|
+
p_restart.add_argument("--no-prompt", action="store_true")
|
|
2180
|
+
p_restart.add_argument("--attach", action="store_true")
|
|
2181
|
+
p_restart.add_argument("--resume", dest="resume", action="store_true", default=None)
|
|
2182
|
+
p_restart.add_argument("--no-resume", dest="resume", action="store_false")
|
|
2183
|
+
|
|
2184
|
+
add("status", cmd_status, "show which agents are running")
|
|
2185
|
+
|
|
2186
|
+
p_attach = add("attach", cmd_attach, "attach to an agent's tmux session")
|
|
2187
|
+
p_attach.add_argument("agent")
|
|
2188
|
+
|
|
2189
|
+
def add_busy_flags(p):
|
|
2190
|
+
p.add_argument(
|
|
2191
|
+
"--queue", action="store_true",
|
|
2192
|
+
help="if the recipient is busy, queue the message instead of failing",
|
|
2193
|
+
)
|
|
2194
|
+
p.add_argument(
|
|
2195
|
+
"--wait", action="store_true",
|
|
2196
|
+
help="block until the recipient is free (or, with --queue, until it is picked up)",
|
|
2197
|
+
)
|
|
2198
|
+
p.add_argument(
|
|
2199
|
+
"--wait-timeout", type=float, default=600,
|
|
2200
|
+
help="seconds to keep waiting (default: 600)",
|
|
2201
|
+
)
|
|
2202
|
+
p.add_argument(
|
|
2203
|
+
"--ignore-busy", action="store_true",
|
|
2204
|
+
help="deliver even if the recipient is mid-task",
|
|
2205
|
+
)
|
|
2206
|
+
|
|
2207
|
+
p_send = add("send", cmd_send, "send a message to an agent (permission-checked)")
|
|
2208
|
+
p_send.add_argument("--to", required=True, help="recipient agent name")
|
|
2209
|
+
p_send.add_argument("--from", dest="sender", help="sender name (default: $SWARM_AGENT or 'user')")
|
|
2210
|
+
p_send.add_argument("--file", help="read the message body from a file")
|
|
2211
|
+
p_send.add_argument("--force", action="store_true", help="bypass the can_talk_to and busy checks")
|
|
2212
|
+
add_busy_flags(p_send)
|
|
2213
|
+
p_send.add_argument("message", nargs="*", help="message text, or '-' to read stdin")
|
|
2214
|
+
|
|
2215
|
+
p_bcast = add("broadcast", cmd_broadcast, "send a message to every agent you may talk to")
|
|
2216
|
+
p_bcast.add_argument("--from", dest="sender")
|
|
2217
|
+
p_bcast.add_argument("--file")
|
|
2218
|
+
p_bcast.add_argument("--force", action="store_true")
|
|
2219
|
+
add_busy_flags(p_bcast)
|
|
2220
|
+
p_bcast.add_argument("message", nargs="*")
|
|
2221
|
+
|
|
2222
|
+
p_sessions = add("sessions", cmd_sessions, "show each agent's recorded conversation id")
|
|
2223
|
+
p_sessions.add_argument("--raw", action="store_true", help="print sessions.yaml verbatim")
|
|
2224
|
+
|
|
2225
|
+
p_queue = add("queue", cmd_queue, "show (or clear) the messages waiting for a busy agent")
|
|
2226
|
+
p_queue.add_argument("agent")
|
|
2227
|
+
p_queue.add_argument("--clear", action="store_true", help="discard everything queued")
|
|
2228
|
+
|
|
2229
|
+
p_idle = add("idle", cmd_idle, "force an agent back to idle if a capture never fired")
|
|
2230
|
+
p_idle.add_argument("agent")
|
|
2231
|
+
p_idle.add_argument("--no-drain", action="store_true", help="do not deliver queued messages")
|
|
2232
|
+
|
|
2233
|
+
p_inbox = add("inbox", cmd_inbox, "print archived messages received by an agent")
|
|
2234
|
+
p_inbox.add_argument("agent", nargs="?")
|
|
2235
|
+
p_inbox.add_argument("-n", "--tail", type=int, default=5)
|
|
2236
|
+
|
|
2237
|
+
p_logs = add("logs", cmd_logs, "print the swarm event log")
|
|
2238
|
+
p_logs.add_argument("agent", nargs="?", help="agent name, or omit for the whole swarm")
|
|
2239
|
+
p_logs.add_argument("-n", "--tail", type=int, default=20)
|
|
2240
|
+
p_logs.add_argument("-f", "--follow", action="store_true")
|
|
2241
|
+
|
|
2242
|
+
p_val = add("validate", cmd_validate, "parse the config and print the resolved swarm")
|
|
2243
|
+
p_val.add_argument("--show-prompts", action="store_true", help="also print each agent's first prompt")
|
|
2244
|
+
|
|
2245
|
+
p_hook = add("hook", cmd_hook, "internal: called by an agent's completion hook")
|
|
2246
|
+
p_hook.add_argument("type", choices=["claude", "codex", "generic"])
|
|
2247
|
+
p_hook.add_argument("payload", nargs="?", help="JSON payload (codex passes it as argv)")
|
|
2248
|
+
p_hook.add_argument("--agent", help="override the detected agent name")
|
|
2249
|
+
|
|
2250
|
+
p_watch = add("watch", cmd_watch, "internal: poll an agent's tmux pane for completed turns")
|
|
2251
|
+
p_watch.add_argument("agent")
|
|
2252
|
+
|
|
2253
|
+
return parser
|
|
2254
|
+
|
|
2255
|
+
|
|
2256
|
+
def main(argv: list[str] | None = None) -> int:
|
|
2257
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
2258
|
+
|
|
2259
|
+
# `swarm agents.yaml` and `swarm ./x.yaml up` both mean "up with this config".
|
|
2260
|
+
if argv and not argv[0].startswith("-") and argv[0].endswith((".yaml", ".yml")):
|
|
2261
|
+
path = argv.pop(0)
|
|
2262
|
+
argv = [*(argv or ["up"]), "-c", path]
|
|
2263
|
+
|
|
2264
|
+
args = build_parser().parse_args(argv)
|
|
2265
|
+
try:
|
|
2266
|
+
return args.func(args)
|
|
2267
|
+
except (ConfigError, SwarmError) as exc:
|
|
2268
|
+
die(str(exc))
|
|
2269
|
+
except subprocess.CalledProcessError as exc:
|
|
2270
|
+
die(f"command failed: {exc}")
|
|
2271
|
+
except KeyboardInterrupt:
|
|
2272
|
+
return 130
|
|
2273
|
+
return 0
|
|
2274
|
+
|
|
2275
|
+
|
|
2276
|
+
if __name__ == "__main__":
|
|
2277
|
+
sys.exit(main())
|