agentainer 0.1.4 → 0.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -108,6 +108,7 @@ the CLI, and types each agent's first prompt into it.
108
108
 
109
109
  ```bash
110
110
  npm install -g agentainer
111
+ agentainer --version # print the installed version (also: agentainer -v)
111
112
  agentainer doctor # check tmux/python3 are present; report which agent CLIs it found
112
113
  ```
113
114
 
package/lib/config.py CHANGED
@@ -242,6 +242,8 @@ class SwarmConfig:
242
242
  pane_scrollback: int = 400
243
243
  tmux_history_limit: int = 50000
244
244
  tmux_mouse: bool = True
245
+ supervise: bool = True
246
+ supervise_interval_ms: int = 15000
245
247
  warnings: list[str] = field(default_factory=list)
246
248
 
247
249
  @property
@@ -370,6 +372,8 @@ def load(path: str | os.PathLike) -> SwarmConfig:
370
372
  # Pass 1: materialise agents without resolving peer references.
371
373
  agents: list[Agent] = []
372
374
  seen: set[str] = set()
375
+ # Warnings collected while resolving each agent (e.g. capture upgrades).
376
+ capture_warnings: list[str] = []
373
377
  for index, raw in enumerate(raw_agents):
374
378
  if not isinstance(raw, dict):
375
379
  raise ConfigError(f"agents[{index}]: must be a mapping")
@@ -407,6 +411,19 @@ def load(path: str | os.PathLike) -> SwarmConfig:
407
411
  )
408
412
  if capture == "auto":
409
413
  capture = str(tconf.get("capture") or "pane")
414
+ # capture: none on a type that HAS a completion hook (claude/codex) removes
415
+ # the agent's only turn-completion signal and leaves the orchestrator blind
416
+ # to a silent turn -- which can wedge the whole swarm. Auto-upgrade to the
417
+ # type's natural capture so the hook keeps the orchestrator informed (and
418
+ # re-enables busy_check / tag parsing / reply reminders, force-disabled for
419
+ # capture: none below). gemini/hermes deliberately keep capture: none.
420
+ if capture == "none" and str(tconf.get("capture")) == "hook":
421
+ capture = "hook"
422
+ capture_warnings.append(
423
+ f"agent {name!r}: capture: none on a {atype} agent gives the "
424
+ f"orchestrator no turn-completion signal -- auto-upgraded to "
425
+ f"capture: hook. Use capture: pane (gemini/hermes) for deliberate-send."
426
+ )
410
427
 
411
428
  boot = raw.get("boot_delay_ms")
412
429
  if boot is None:
@@ -501,8 +518,8 @@ def load(path: str | os.PathLike) -> SwarmConfig:
501
518
  True,
502
519
  f"agent {name}: reply_reminder",
503
520
  ),
504
- resume_args=raw.get("resume_args", tconf.get("resume_args")),
505
- resume_command=raw.get("resume_command", tconf.get("resume_command")),
521
+ resume_args=raw.get("resume_args") or defaults.get("resume_args") or tconf.get("resume_args"),
522
+ resume_command=raw.get("resume_command") or defaults.get("resume_command") or tconf.get("resume_command"),
506
523
  ready_probe=_as_bool(
507
524
  raw.get("ready_probe", defaults.get("ready_probe")),
508
525
  True,
@@ -564,6 +581,7 @@ def load(path: str | os.PathLike) -> SwarmConfig:
564
581
  # Pass 2b: working directories. They may be auto-created under `root`, or
565
582
  # point at an existing project -- possibly one shared by several agents.
566
583
  warnings: list[str] = []
584
+ warnings.extend(capture_warnings)
567
585
  for agent in agents:
568
586
  # No tag parsing means no way to know whether it replied.
569
587
  if not agent.parse_outbound_tags:
@@ -613,6 +631,8 @@ def load(path: str | os.PathLike) -> SwarmConfig:
613
631
  pane_scrollback=int(swarm.get("pane_scrollback", 400)),
614
632
  tmux_history_limit=int(swarm.get("tmux_history_limit", 50000)),
615
633
  tmux_mouse=_as_bool(swarm.get("tmux_mouse"), True, "swarm.tmux_mouse"),
634
+ supervise=_as_bool(swarm.get("supervise"), True, "swarm.supervise"),
635
+ supervise_interval_ms=int(swarm.get("supervise_interval_ms", 15000)),
616
636
  )
617
637
 
618
638
  # Pass 3: build the full first prompt for each agent.
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
 
@@ -2131,6 +2277,13 @@ def cmd_watch(args) -> int:
2131
2277
  return 0
2132
2278
 
2133
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
+
2134
2287
  def select_agents(cfg: SwarmConfig, only: str | None) -> list[Agent]:
2135
2288
  if not only:
2136
2289
  return list(cfg.agents)
@@ -2154,11 +2307,25 @@ def default_config() -> str:
2154
2307
  return "agents.yaml"
2155
2308
 
2156
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
+
2157
2320
  def build_parser() -> argparse.ArgumentParser:
2158
2321
  parser = argparse.ArgumentParser(
2159
2322
  prog=os.environ.get("SWARM_PROG", "agentainer"),
2160
2323
  description="Run a swarm of coding agents (claude, codex, gemini, hermes) in tmux.",
2161
2324
  )
2325
+ parser.add_argument(
2326
+ "-v", "--version", action="version", version=f"agentainer {read_version()}",
2327
+ help="show the Agentainer version and exit",
2328
+ )
2162
2329
  parser.add_argument(
2163
2330
  "-c", "--config", default=default_config(), help="path to the swarm YAML (default: agents.yaml)"
2164
2331
  )
@@ -2265,6 +2432,9 @@ def build_parser() -> argparse.ArgumentParser:
2265
2432
  p_watch = add("watch", cmd_watch, "internal: poll an agent's tmux pane for completed turns")
2266
2433
  p_watch.add_argument("agent")
2267
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
+
2268
2438
  return parser
2269
2439
 
2270
2440
 
@@ -2285,8 +2455,7 @@ def main(argv: list[str] | None = None) -> int:
2285
2455
  die(f"command failed: {exc}")
2286
2456
  except KeyboardInterrupt:
2287
2457
  return 130
2288
- return 0
2289
2458
 
2290
2459
 
2291
- if __name__ == "__main__":
2460
+ if __name__ == "__main__": # pragma: no cover - exercised by running the module as a script
2292
2461
  sys.exit(main())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentainer",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
4
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",