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