agentainer 0.1.7 → 2.0.1

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