agentainer 0.1.3 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/swarm.py CHANGED
@@ -384,10 +384,6 @@ def pane_text(session: str, scrollback: int = 0) -> str:
384
384
  return ""
385
385
 
386
386
 
387
- def visible_pane(session: str) -> str:
388
- return pane_text(session)
389
-
390
-
391
387
  def needle_for(body: str) -> str:
392
388
  """The *tail* of the text, which is what stays on screen after a paste.
393
389
 
@@ -1318,6 +1314,131 @@ def watcher_alive(cfg: SwarmConfig, agent: Agent) -> bool:
1318
1314
  return False
1319
1315
 
1320
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
+
1321
1442
  READY_TOKEN = "zqxswarmready"
1322
1443
 
1323
1444
 
@@ -1720,6 +1841,7 @@ def cmd_up(args) -> int:
1720
1841
  continue
1721
1842
  info(f"{agent.name}: restarting")
1722
1843
  stop_watcher(cfg, agent)
1844
+ stop_supervisor(cfg)
1723
1845
  tmux("kill-session", "-t", f"={agent.session}", check=False, capture=True)
1724
1846
 
1725
1847
  resume_cmd = None
@@ -1801,11 +1923,28 @@ def cmd_up(args) -> int:
1801
1923
  if agent.capture == "pane":
1802
1924
  start_watcher(cfg, agent)
1803
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
+
1804
1931
  print()
1805
1932
  info(f"swarm {cfg.name!r} is up with {len(started)} agent(s)")
1806
1933
  info(f"attach with: tmux attach -t {started[0].session}")
1807
1934
  info(f"or: {SWARM_HOME / 'agentainer'} attach {started[0].name}")
1808
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
+
1809
1948
  if args.attach:
1810
1949
  os.execvp("tmux", ["tmux", "attach", "-t", started[0].session])
1811
1950
  return 0
@@ -1813,6 +1952,10 @@ def cmd_up(args) -> int:
1813
1952
 
1814
1953
  def cmd_down(args) -> int:
1815
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)
1816
1959
  for agent in select_agents(cfg, args.only):
1817
1960
  stop_watcher(cfg, agent)
1818
1961
  if session_exists(agent.session):
@@ -1870,6 +2013,9 @@ def cmd_status(args) -> int:
1870
2013
  cells = [str(c).ljust(widths[i]) for i, c in enumerate(row)]
1871
2014
  cells[2] = f"{colour}{cells[2]}\033[0m"
1872
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)")
1873
2019
  return 0
1874
2020
 
1875
2021
 
@@ -2117,7 +2263,24 @@ def cmd_validate(args) -> int:
2117
2263
 
2118
2264
  def cmd_watch(args) -> int:
2119
2265
  cfg = cfgmod.load(args.config)
2120
- run_watcher(cfg, cfg.get(args.agent))
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)
2121
2284
  return 0
2122
2285
 
2123
2286
 
@@ -2255,6 +2418,9 @@ def build_parser() -> argparse.ArgumentParser:
2255
2418
  p_watch = add("watch", cmd_watch, "internal: poll an agent's tmux pane for completed turns")
2256
2419
  p_watch.add_argument("agent")
2257
2420
 
2421
+ p_sup = add("supervise", cmd_supervise, "internal: background liveness watchdog")
2422
+ p_sup.add_argument("names", nargs="*", help="agents to watch (default: all)")
2423
+
2258
2424
  return parser
2259
2425
 
2260
2426
 
@@ -2275,8 +2441,7 @@ def main(argv: list[str] | None = None) -> int:
2275
2441
  die(f"command failed: {exc}")
2276
2442
  except KeyboardInterrupt:
2277
2443
  return 130
2278
- return 0
2279
2444
 
2280
2445
 
2281
- if __name__ == "__main__":
2446
+ if __name__ == "__main__": # pragma: no cover - exercised by running the module as a script
2282
2447
  sys.exit(main())
package/llms.txt CHANGED
@@ -275,6 +275,32 @@ stops at `max_forward_hops`. A message you send with `swarm send` resets it to z
275
275
  reproduce → diagnose → fix → verify.
276
276
  - `examples/existing-repo.yaml` — two agents pairing inside one existing checkout,
277
277
  using `create_workdirs: false` so a wrong path fails instead of being created.
278
+ - `examples/red-team.yaml` — adversarial triad: an attacker and defender argue
279
+ through a referee over one existing checkout.
280
+ - `examples/debate.yaml` — two advocates argue opposite sides; a judge
281
+ cross-examines and rules.
282
+ - `examples/writers-room.yaml` — an editor drives a writer, fact-checker and
283
+ critic to a finished article.
284
+ - `examples/incident-response.yaml` — a commander coordinates an investigator,
285
+ responder and scribe under time pressure (hub and spoke).
286
+ - `examples/tdd-pingpong.yaml` — a tester and coder grow a feature test-first,
287
+ one test at a time, via `forward_responses_to`.
288
+ - `examples/brainstorm.yaml` — three idea-generators fan out from a facilitator,
289
+ then a synthesiser ranks them.
290
+ - `examples/localization.yaml` — translate → review → back-check, auto-forwarded
291
+ via `forward_responses_to`; a `NEEDS ANOTHER PASS` verdict loops back to
292
+ the translator with a `<swarm-send>` block.
293
+ - `examples/multi-language-broadcast.yaml` — one translator broadcasts a source to
294
+ N language reviewers; a consolidator compiles and broadcasts back. Built on
295
+ `broadcast`.
296
+ - `examples/code-review-broadcast.yaml` — a coordinator broadcasts a PR to
297
+ specialist reviewers (correctness/security/perf); each replies to the
298
+ coordinator, who synthesizes. Built on `broadcast`.
299
+ - `examples/ping-pong.yaml` — two agents trade a unit of work, each broadcast
300
+ naming who is up next (round-robin via `broadcast`, distinct from
301
+ tdd-pingpong's fixed `forward_responses_to`).
302
+ - `agents.example.yaml` — the fully annotated reference config; every key is
303
+ documented inline.
278
304
 
279
305
  ## Recipes
280
306
 
package/package.json CHANGED
@@ -1,17 +1,24 @@
1
1
  {
2
2
  "name": "agentainer",
3
- "version": "0.1.3",
4
- "description": "Run a configurable swarm of coding-agent CLIs (Claude Code, Codex, Gemini) in tmux and let them talk to each other.",
3
+ "version": "0.1.5",
4
+ "description": "Zero-dependency multi-agent orchestrator: run a team of AI coding agents (Claude Code, Codex, Gemini, Hermes) side by side in tmux with a YAML-defined ACL.",
5
5
  "keywords": [
6
6
  "agents",
7
+ "ai-agents",
7
8
  "ai",
9
+ "llm",
10
+ "agentic",
11
+ "claude-code",
8
12
  "claude",
9
13
  "codex",
10
14
  "gemini",
15
+ "hermes",
11
16
  "tmux",
12
17
  "orchestration",
13
18
  "multi-agent",
14
- "swarm"
19
+ "swarm",
20
+ "prompt-routing",
21
+ "python"
15
22
  ],
16
23
  "homepage": "https://github.com/mehmetcanfarsak/AgentSwarm#readme",
17
24
  "bugs": {