agentainer 0.1.6 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) 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/brainstorm.yaml +27 -128
  6. package/examples/bug-hunt.yaml +51 -96
  7. package/examples/code-review.yaml +73 -0
  8. package/examples/debate.yaml +16 -90
  9. package/examples/incident-response.yaml +52 -109
  10. package/examples/localization.yaml +56 -123
  11. package/examples/quickstart.yaml +48 -0
  12. package/examples/research.yaml +25 -0
  13. package/examples/software-company.yaml +71 -128
  14. package/examples/tdd-pingpong.yaml +36 -68
  15. package/examples/writers-room.yaml +49 -111
  16. package/hooks/claude_stop.sh +5 -3
  17. package/hooks/codex_notify.sh +4 -3
  18. package/lib/cli.py +929 -0
  19. package/lib/config.py +247 -305
  20. package/lib/hooks.py +246 -0
  21. package/lib/lock.py +75 -0
  22. package/lib/log.py +64 -0
  23. package/lib/mail.py +634 -0
  24. package/lib/minyaml.py +1 -39
  25. package/lib/reconcile.py +473 -0
  26. package/lib/sessions.py +223 -0
  27. package/lib/supervisor.py +216 -0
  28. package/lib/telegram.py +372 -0
  29. package/lib/tmux.py +355 -0
  30. package/lib/turn.py +159 -0
  31. package/lib/ui.py +1020 -0
  32. package/llms.txt +145 -429
  33. package/package.json +9 -7
  34. package/scripts/check-deps.js +18 -61
  35. package/ui/app.js +869 -0
  36. package/ui/index.html +348 -0
  37. package/agents.example.yaml +0 -257
  38. package/examples/code-review-broadcast.yaml +0 -109
  39. package/examples/existing-repo.yaml +0 -74
  40. package/examples/multi-language-broadcast.yaml +0 -127
  41. package/examples/ping-pong.yaml +0 -89
  42. package/examples/red-team.yaml +0 -117
  43. package/examples/research-swarm.yaml +0 -129
  44. package/lib/swarm.py +0 -2461
package/lib/cli.py ADDED
@@ -0,0 +1,929 @@
1
+ #!/usr/bin/env python3
2
+ """Agentainer -- the v2 command line entry point.
3
+
4
+ This is the thin shell that ties the tested core modules (config, mail, turn,
5
+ hooks, tmux, sessions, log) into the operator-facing subcommands. Every handler
6
+ here is intentionally small: all the hard work -- routing, ACL, read-state,
7
+ queueing, turn-detection, resume -- lives in those modules. See
8
+ ``ProjectPlan.md`` (§26 build phases) and the v1 ``lib/swarm.py`` CLI it ports.
9
+
10
+ Zero runtime dependencies: Python stdlib + the bundled lib/ modules only.
11
+
12
+ Branding: "swarm" is retired -- it's Agentainer everywhere (decision D21).
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import argparse
18
+ import json
19
+ import os
20
+ import secrets
21
+ import shlex
22
+ import shutil
23
+ import subprocess
24
+ import sys
25
+ import time
26
+ from pathlib import Path
27
+
28
+ # The bash launcher and bin/agentainer.js both exec this file as a script, so
29
+ # Python puts lib/ on sys.path[0]; the bare imports below resolve there.
30
+ import config as cfgmod # noqa: E402
31
+ from config import ConfigError # noqa: E402
32
+ import hooks # noqa: E402
33
+ import log # noqa: E402
34
+ import mail # noqa: E402
35
+ import sessions # noqa: E402
36
+ import tmux # noqa: E402
37
+ import turn # noqa: E402
38
+ import ui # noqa: E402
39
+ import reconcile # noqa: E402
40
+
41
+ # Repo root: AGENTAINER_HOME overrides, else this file's grandparent (lib/..).
42
+ AGENTAINER_HOME = Path(
43
+ os.environ.get("AGENTAINER_HOME") or Path(__file__).resolve().parent.parent
44
+ )
45
+
46
+
47
+ # --------------------------------------------------------------------------
48
+ # small CLI utilities
49
+ # --------------------------------------------------------------------------
50
+
51
+
52
+ def info(msg: str) -> None:
53
+ print(f":: {msg}", file=sys.stderr)
54
+
55
+
56
+ def warn(msg: str) -> None:
57
+ print(f"!! {msg}", file=sys.stderr)
58
+
59
+
60
+ def die(msg: str) -> None:
61
+ """Print an error and exit non-zero. Never returns."""
62
+ print(f"!! {msg}", file=sys.stderr)
63
+ sys.exit(1)
64
+
65
+
66
+ def _import_supervisor():
67
+ """Lazily import the supervisor module.
68
+
69
+ ``supervisor`` is the only core module that may be absent in a partially
70
+ wired checkout; importing it lazily keeps *every other* command working and
71
+ lets the supervisor-dependent paths degrade gracefully (caught by callers).
72
+ """
73
+ import supervisor # noqa: F401 - may be injected via sys.modules in tests
74
+
75
+ return supervisor
76
+
77
+
78
+ def _supervisor_alive(cfg) -> bool:
79
+ try:
80
+ return _import_supervisor().supervisor_alive(cfg)
81
+ except ImportError:
82
+ return False
83
+
84
+
85
+ # --------------------------------------------------------------------------
86
+ # config resolution / context discovery (port of v1 discover_context)
87
+ # --------------------------------------------------------------------------
88
+
89
+
90
+ def default_config() -> str:
91
+ """AGENTAINER_CONFIG, else ./agentainer.yaml, else $AGENTAINER_HOME/agentainer.yaml."""
92
+ from_env = os.environ.get("AGENTAINER_CONFIG")
93
+ if from_env:
94
+ return from_env
95
+ local = Path.cwd() / "agentainer.yaml"
96
+ if local.is_file():
97
+ return str(local)
98
+ return str(AGENTAINER_HOME / "agentainer.yaml")
99
+
100
+
101
+ def read_version() -> str:
102
+ """Best-effort read of the package version from package.json (single source
103
+ of truth), falling back to 'unknown' if it cannot be found or parsed."""
104
+ try:
105
+ data = json.loads((AGENTAINER_HOME / "package.json").read_text())
106
+ return str(data.get("version") or "unknown")
107
+ except Exception:
108
+ return "unknown"
109
+
110
+
111
+ def config_from_state() -> str | None:
112
+ """Walk up from the cwd looking for the .agentainer/state.json written by up."""
113
+ probe = Path.cwd().resolve()
114
+ for candidate in [probe, *probe.parents]:
115
+ state = candidate / ".agentainer" / "state.json"
116
+ if state.is_file():
117
+ try:
118
+ return json.loads(state.read_text()).get("config")
119
+ except (OSError, json.JSONDecodeError):
120
+ return None
121
+ return None
122
+
123
+
124
+ def agent_from_cwd(cfg) -> str | None:
125
+ cwd = Path.cwd().resolve()
126
+ for agent in cfg.agents:
127
+ workdir = agent.workdir.resolve()
128
+ if cwd == workdir or workdir in cwd.parents:
129
+ return agent.name
130
+ return None
131
+
132
+
133
+ def discover_context(explicit_config: str | None, explicit_agent: str | None):
134
+ """Figure out which swarm + agent we are, from argv, env, or the filesystem.
135
+
136
+ A hook runs inside the agent's own process, so it may inherit an
137
+ AGENTAINER_CONFIG (or fall back to an unrelated ./agentainer.yaml) that does
138
+ not describe it. Each candidate config is therefore only accepted if it
139
+ actually contains the calling agent.
140
+ """
141
+ candidates = [explicit_config, os.environ.get("AGENTAINER_CONFIG"), config_from_state()]
142
+ seen: list[str] = []
143
+
144
+ for path in candidates:
145
+ if not path or path in seen:
146
+ continue
147
+ seen.append(path)
148
+ if not Path(path).is_file():
149
+ continue
150
+ try:
151
+ cfg = cfgmod.load(path)
152
+ except ConfigError:
153
+ continue
154
+ name = explicit_agent or os.environ.get("AGENTAINER_AGENT") or agent_from_cwd(cfg)
155
+ if name and name in cfg.names():
156
+ return cfg, cfg.get(name)
157
+
158
+ raise ConfigError(
159
+ "cannot work out which swarm/agent is calling. Set AGENTAINER_AGENT and "
160
+ "AGENTAINER_CONFIG, or run from inside an agent's working directory. "
161
+ f"Configs tried: {', '.join(seen) or 'none'}"
162
+ )
163
+
164
+
165
+ def select_agents(cfg, only: str | None):
166
+ if not only:
167
+ return list(cfg.agents)
168
+ names = [n.strip() for n in only.split(",") if n.strip()]
169
+ return [cfg.get(n) for n in names]
170
+
171
+
172
+ # --------------------------------------------------------------------------
173
+ # message reading (port of v1 read_message)
174
+ # --------------------------------------------------------------------------
175
+
176
+
177
+ def read_message(args) -> str:
178
+ if getattr(args, "file", None):
179
+ return Path(args.file).read_text()
180
+ msg = getattr(args, "message", None)
181
+ if not msg or msg == ["-"]:
182
+ if sys.stdin.isatty():
183
+ die("no message given (pass it as an argument, with --file, or on stdin)")
184
+ return sys.stdin.read()
185
+ return " ".join(msg)
186
+
187
+
188
+ # --------------------------------------------------------------------------
189
+ # agent launch (port of v1 start_agent -> open one tmux session, send role)
190
+ # --------------------------------------------------------------------------
191
+
192
+
193
+ def start_agent(cfg, agent, extra_env=None, resume_cmd: str | None = None) -> None:
194
+ """Launch *agent* in a fresh tmux session; optionally resume a conversation.
195
+
196
+ Creates the workdir (when allowed), writes the turn-state, exports the
197
+ session environment, runs the agent command, and returns. The first prompt
198
+ (the standby message: agent.role wrapped with a "wait until notified" notice)
199
+ is pasted by the caller once the pane is ready.
200
+ """
201
+ resume = resume_cmd is not None
202
+ if not agent.workdir.is_dir():
203
+ if not agent.create_workdir:
204
+ # config.load already rejects this case, so this is a belt-and-braces
205
+ # guard for any caller that builds an Agent without going through load.
206
+ raise ConfigError( # pragma: no cover
207
+ f"{agent.name}: workdir does not exist: {agent.workdir} "
208
+ "(create_workdir is false)"
209
+ )
210
+ agent.workdir.mkdir(parents=True, exist_ok=True)
211
+ info(f"{agent.name}: created {agent.workdir}")
212
+
213
+ # A newly launched CLI has no turn in flight (resumed or not).
214
+ turn.write_turn_state(cfg, agent.name, {"delivered": 0, "completed": 0, "since": 0, "by": None})
215
+
216
+ env = sessions.session_env(cfg, agent, extra_env or {})
217
+ exports = " ".join(f"export {k}={shlex.quote(v)};" for k, v in env.items())
218
+
219
+ command = resume_cmd or agent.command
220
+ inner = (
221
+ f"{exports} "
222
+ f"cd {shlex.quote(str(agent.workdir))} || exit 1; "
223
+ f"{command}; "
224
+ f'status=$?; printf "\\n[agentainer] agent %s exited (status %s)\\n" '
225
+ f"{shlex.quote(agent.name)} \"$status\"; "
226
+ 'exec "${SHELL:-bash}" -l'
227
+ )
228
+ launcher = f"exec bash -lc {shlex.quote(inner)}"
229
+
230
+ tmux.tmux(
231
+ "new-session", "-d", "-s", agent.session, "-x", "220", "-y", "50",
232
+ "-c", str(agent.workdir), launcher,
233
+ )
234
+ info(f"started {agent.name} ({agent.type}) in tmux session {agent.session!r}")
235
+
236
+
237
+ def launch_agent_full(cfg, agent, resume_cmd: str | None = None) -> None:
238
+ """Start *agent* in a tmux session AND deliver its first prompt (standby).
239
+
240
+ Combines ``start_agent`` with the readiness-wait + standby-prompt paste. The
241
+ first prompt is ``mail.standby_prompt`` -- the agent's role wrapped with a
242
+ "no task yet, don't send anything, you'll be notified when a real task
243
+ arrives" notice -- so a fresh swarm doesn't immediately start peers mailing
244
+ each other. Shared with the P4 reconcile path so a newly-added agent boots
245
+ identically to ``up``. A resumed agent gets its session recreated but no
246
+ first prompt (its prior conversation is restored).
247
+ """
248
+ extra_env = hooks.install_turn_detection(agent)
249
+ start_agent(cfg, agent, extra_env, resume_cmd)
250
+ if resume_cmd is not None:
251
+ info(f"{agent.name}: resumed, not re-sending the first prompt")
252
+ return
253
+ # The first prompt is a STANDBY message: the agent's role (identity + mailbox
254
+ # protocol) wrapped with an explicit "no task yet -- do NOT send anything,
255
+ # you'll be notified when a real task arrives" notice. This keeps a proactive
256
+ # model from mailing its peers the instant the swarm comes up, before any
257
+ # human-assigned task exists. See mail.standby_prompt.
258
+ try:
259
+ if agent.ready_probe and not tmux.wait_until_ready(cfg, agent):
260
+ warn(
261
+ f"{agent.name}: input box never responded within "
262
+ f"{cfg.ready_timeout_ms}ms; sending the prompt anyway"
263
+ )
264
+ first = mail.standby_prompt(cfg, agent)
265
+ if tmux.paste_into(cfg, agent.session, first):
266
+ turn.mark_turn_started(cfg, agent.name, "user")
267
+ info(f"sent first prompt to {agent.name}")
268
+ else:
269
+ warn(f"{agent.name}: first prompt may not have been delivered")
270
+ log.log_event(cfg, agent.name, "first_prompt", text=first)
271
+ except tmux.SwarmError as exc:
272
+ warn(f"{agent.name}: could not send first prompt: {exc}")
273
+ time.sleep(cfg.send_delay_ms / 1000.0)
274
+
275
+
276
+ # --------------------------------------------------------------------------
277
+ # lifecycle handlers
278
+ # --------------------------------------------------------------------------
279
+
280
+
281
+ def cmd_up(args) -> int:
282
+ cfg = cfgmod.load(args.config)
283
+ if not shutil.which("tmux"):
284
+ die("tmux is required but was not found on PATH")
285
+
286
+ selected = select_agents(cfg, args.only)
287
+ for message in cfg.warnings:
288
+ warn(message)
289
+
290
+ for directory in (cfg.runtime, cfg.log_dir, cfg.queue_dir, cfg.run_dir):
291
+ directory.mkdir(parents=True, exist_ok=True)
292
+
293
+ # Set the globals the agent panes inherit, then tear the holder down once
294
+ # the real sessions keep the tmux server alive.
295
+ setup_holder = tmux.configure_tmux(cfg)
296
+ mail.init_mailboxes(cfg)
297
+
298
+ # Resume is the default (see SwarmConfig.resume); `--no-resume` opts out and
299
+ # `swarm.resume: false` in the config disables it. `explicit_resume` tracks
300
+ # whether the operator asked for it on purpose, so we only nag about a missing
301
+ # conversation when they did -- a default first launch is silent.
302
+ explicit_resume = args.resume is True
303
+ resume = cfg.resume if args.resume is None else args.resume
304
+ recorded = sessions.read_sessions(cfg) if resume else {}
305
+
306
+ started: list = []
307
+ for agent in selected:
308
+ if tmux.session_exists(agent.session):
309
+ if not getattr(args, "restart", False):
310
+ warn(f"{agent.name}: session {agent.session!r} already exists, skipping")
311
+ continue
312
+ info(f"{agent.name}: restarting")
313
+ tmux.tmux("kill-session", "-t", f"={agent.session}", check=False, capture=True)
314
+
315
+ resume_cmd = None
316
+ if resume:
317
+ session_id = (recorded.get(agent.name) or {}).get("session_id")
318
+ if not session_id:
319
+ if explicit_resume:
320
+ warn(
321
+ f"{agent.name}: no recorded conversation; starting a fresh one"
322
+ )
323
+ # Implicit default resume with nothing recorded: start fresh, quietly.
324
+ else:
325
+ resume_cmd = sessions.resume_command(cfg, agent, session_id)
326
+ if resume_cmd:
327
+ info(f"{agent.name}: resuming conversation {session_id}")
328
+ else:
329
+ warn(
330
+ f"{agent.name}: type {agent.type!r} has no resume recipe "
331
+ "(set resume_args or resume_command); starting a fresh conversation"
332
+ )
333
+
334
+ # Per-type turn-completion wiring + open session + deliver first prompt.
335
+ launch_agent_full(cfg, agent, resume_cmd)
336
+ started.append(agent)
337
+
338
+ if setup_holder:
339
+ tmux.tmux("kill-session", "-t", f"={setup_holder}", check=False, capture=True)
340
+
341
+ if not started:
342
+ info("nothing to start")
343
+ return 0
344
+
345
+ # The supervisor is the heartbeat the event-driven design lacks: it reconciles
346
+ # dead/stale agents on a timer so one silent agent cannot wedge the swarm.
347
+ if cfg.supervise and not getattr(args, "no_supervise", False):
348
+ try:
349
+ _import_supervisor().start_supervisor(cfg, [a.name for a in started])
350
+ except ImportError:
351
+ warn("supervisor module not available; running without the liveness supervisor")
352
+
353
+ print()
354
+ info(f"swarm {cfg.name!r} is up with {len(started)} agent(s)")
355
+ if started:
356
+ info(f"attach with: tmux attach -t {started[0].session}")
357
+ # Surface the exact serve command so the operator doesn't have to recall
358
+ # the flags. A token is required for any non-loopback bind (CLAUDE.md
359
+ # invariant), so we generate one and print it -- drop --host/--token for
360
+ # the safe 127.0.0.1-only bind.
361
+ token = gen_ui_token()
362
+ info(
363
+ "you can use the UI with: agentainer serve --host 0.0.0.0 "
364
+ f"-c {cfg.path} --token {token} --port 8000"
365
+ )
366
+ return 0
367
+
368
+
369
+ def cmd_down(args) -> int:
370
+ cfg = cfgmod.load(args.config)
371
+ if not args.only:
372
+ try:
373
+ _import_supervisor().stop_supervisor(cfg)
374
+ except ImportError:
375
+ pass
376
+ for agent in select_agents(cfg, args.only):
377
+ if tmux.session_exists(agent.session):
378
+ tmux.tmux("kill-session", "-t", f"={agent.session}", check=False, capture=True)
379
+ info(f"stopped {agent.name}")
380
+ else:
381
+ info(f"{agent.name}: not running")
382
+ return 0
383
+
384
+
385
+ def cmd_restart(args) -> int:
386
+ cmd_down(args)
387
+ args.restart = True
388
+ return cmd_up(args)
389
+
390
+
391
+ def cmd_remove_session(args) -> int:
392
+ """Delete every piece of Agentainer-generated state for the swarm.
393
+
394
+ This is the escape hatch from the default-resume behaviour: after
395
+ ``remove-session`` the next ``up`` finds no recorded conversations and starts
396
+ fresh for every agent.
397
+
398
+ It removes two categories of state (both gitignored, never shipped -- see
399
+ CLAUDE.md): the orchestrator runtime ``.agentainer/`` (sessions.yaml with the
400
+ conversation ids, the per-agent queue, turn state, the durable log, run dir)
401
+ and each agent's five mailbox folders (inbox/outbox/read/sent/failed) where any
402
+ in-flight mail lives. It never touches the agent workspaces' own files (source
403
+ code) or the config.
404
+
405
+ Refuses while any agent (or the supervisor) is still running, because pulling
406
+ state out from under a live agent corrupts it -- ``down`` first.
407
+ """
408
+ cfg = cfgmod.load(args.config)
409
+
410
+ if shutil.which("tmux"):
411
+ for agent in cfg.agents:
412
+ if tmux.session_exists(agent.session):
413
+ die(
414
+ f"{agent.name} is still running -- run `down` first, "
415
+ "then `remove-session`"
416
+ )
417
+ if _supervisor_alive(cfg):
418
+ die("the liveness supervisor is still running -- run `down` first")
419
+
420
+ removed: list[Path] = []
421
+ if cfg.runtime.exists():
422
+ shutil.rmtree(cfg.runtime)
423
+ removed.append(cfg.runtime)
424
+ for agent in cfg.agents:
425
+ mp = cfg.mail_paths(agent)
426
+ for folder in (mp.inbox, mp.outbox, mp.read, mp.sent, mp.failed):
427
+ if folder.exists():
428
+ shutil.rmtree(folder)
429
+ removed.append(folder)
430
+
431
+ if not removed:
432
+ info("nothing to remove -- the swarm is already clean")
433
+ return 0
434
+ info(f"removed Agentainer session data ({len(removed)} path(s)):")
435
+ for path in removed:
436
+ info(f" {path}")
437
+ return 0
438
+
439
+
440
+ def cmd_status(args) -> int:
441
+ cfg = cfgmod.load(args.config)
442
+ print(f"swarm: {cfg.name} root: {cfg.root}")
443
+ for agent in cfg.agents:
444
+ running = tmux.session_exists(agent.session)
445
+ if not running:
446
+ turn_s = "-"
447
+ elif not agent.busy_check:
448
+ turn_s = "untracked"
449
+ else:
450
+ state = turn.busy_info(cfg, agent)
451
+ turn_s = f"busy {state['age_s']}s" if state else "idle"
452
+ q = cfg.queue_dir / agent.name
453
+ depth = len([f for f in q.iterdir() if f.is_file()]) if q.is_dir() else 0
454
+ inbox = cfg.mail_paths(agent).inbox
455
+ unread = len([f for f in inbox.iterdir() if f.is_file()]) if inbox.is_dir() else 0
456
+ print(
457
+ f" {agent.name} ({agent.type}) "
458
+ f"{'up' if running else 'down'} {turn_s} "
459
+ f"queue={depth} unread={unread} "
460
+ f"talks={', '.join(agent.can_talk_to) or '-'}"
461
+ )
462
+ print(f"supervisor: {'alive' if _supervisor_alive(cfg) else 'down'}")
463
+ return 0
464
+
465
+
466
+ def cmd_attach(args) -> int:
467
+ cfg = cfgmod.load(args.config)
468
+ agent = cfg.get(args.agent)
469
+ if not tmux.session_exists(agent.session):
470
+ die(f"{agent.name} is not running")
471
+ os.execvp("tmux", ["tmux", "attach", "-t", agent.session])
472
+ return 0
473
+
474
+
475
+ def cmd_send(args) -> int:
476
+ cfg = cfgmod.load(args.config)
477
+ text = read_message(args)
478
+ sender = args.sender or os.environ.get("AGENTAINER_AGENT") or "user"
479
+
480
+ if sender == "user" or sender not in cfg.names():
481
+ # The operator (or UI stand-in) sends mail as the virtual user.
482
+ mail.send_as_user(cfg, args.to, text)
483
+ info(f"user -> {args.to}: delivered")
484
+ return 0
485
+
486
+ # Simulate a real agent sending: drop the message in its outbox, then run the
487
+ # same sweep the completion hook would, so routing + ACL actually execute.
488
+ agent = cfg.get(sender)
489
+ mp = cfg.mail_paths(agent)
490
+ out_dir = mp.outbox / args.to
491
+ out_dir.mkdir(parents=True, exist_ok=True)
492
+ (out_dir / f"{mail.new_message_id()}.md").write_text(text)
493
+ mail.on_stop(cfg, sender)
494
+ info(f"{sender} -> {args.to}: routed")
495
+ return 0
496
+
497
+
498
+ def cmd_user(args) -> int:
499
+ cfg = cfgmod.load(args.config)
500
+ cmd = args.user_cmd
501
+ if cmd == "available":
502
+ mail.set_user_available(cfg, True)
503
+ info("user is now available")
504
+ elif cmd == "away":
505
+ mail.set_user_available(cfg, False)
506
+ info("user is now away")
507
+ elif cmd == "inbox":
508
+ q = cfg.queue_dir / "user"
509
+ files = sorted(f for f in q.iterdir() if f.is_file()) if q.is_dir() else []
510
+ if not files:
511
+ print("user: no mail")
512
+ return 0
513
+ for path in files:
514
+ print(f"\n--- {path.name} ---")
515
+ print(path.read_text().rstrip())
516
+ elif cmd == "send":
517
+ text = read_message(args)
518
+ mail.send_as_user(cfg, args.to, text)
519
+ info(f"user -> {args.to}: delivered")
520
+ return 0
521
+
522
+
523
+ def cmd_sessions(args) -> int:
524
+ cfg = cfgmod.load(args.config)
525
+ agents = sessions.read_sessions(cfg)
526
+ if not agents:
527
+ print(f"no conversations recorded yet ({cfg.sessions_file})")
528
+ print("They are written as each agent finishes its first turn.")
529
+ return 0
530
+ if args.raw:
531
+ print(cfg.sessions_file.read_text().rstrip())
532
+ return 0
533
+ print(f"{cfg.sessions_file}\n")
534
+ for name in cfg.names():
535
+ entry = agents.get(name)
536
+ if not entry:
537
+ print(f" {name}: -")
538
+ continue
539
+ print(f" {name} ({entry.get('type')})")
540
+ print(f" conversation: {entry.get('session_id')}")
541
+ print(f" last seen: {entry.get('updated_at')}")
542
+ return 0
543
+
544
+
545
+ def cmd_queue(args) -> int:
546
+ cfg = cfgmod.load(args.config)
547
+ agent = cfg.get(args.agent)
548
+ if args.clear:
549
+ q = cfg.queue_dir / agent.name
550
+ dropped = 0
551
+ if q.is_dir():
552
+ for f in q.iterdir():
553
+ if f.is_file():
554
+ f.unlink()
555
+ dropped += 1
556
+ info(f"{agent.name}: dropped {dropped} queued message(s)")
557
+ return 0
558
+ q = cfg.queue_dir / agent.name
559
+ items = sorted(f for f in q.iterdir() if f.is_file()) if q.is_dir() else []
560
+ state = turn.busy_info(cfg, agent)
561
+ status = f"busy for {state['age_s']}s (task from {state['by']})" if state else "idle"
562
+ print(f"{agent.name}: {status}, {len(items)} message(s) queued")
563
+ for index, item in enumerate(items, 1):
564
+ first = item.read_text().strip().splitlines()[0][:70]
565
+ print(f" {index}. {item.name}: {first}")
566
+ return 0
567
+
568
+
569
+ def cmd_idle(args) -> int:
570
+ """Force an agent back to idle -- the escape hatch when a capture never fired."""
571
+ cfg = cfgmod.load(args.config)
572
+ agent = cfg.get(args.agent)
573
+ turn.mark_turn_finished(cfg, agent.name)
574
+ info(f"{agent.name}: marked idle")
575
+ if not args.no_drain:
576
+ mail.process_read_folder(cfg, agent.name)
577
+ return 0
578
+
579
+
580
+ def cmd_inbox(args) -> int:
581
+ cfg = cfgmod.load(args.config)
582
+ name = args.agent or os.environ.get("AGENTAINER_AGENT")
583
+ if not name:
584
+ die("specify an agent: agentainer inbox <agent>")
585
+ agent = cfg.get(name)
586
+ box = cfg.mail_paths(agent).inbox
587
+ if not box.is_dir() or not any(box.iterdir()):
588
+ print(f"{name}: inbox is empty")
589
+ return 0
590
+ for path in sorted(box.iterdir()):
591
+ if path.is_file():
592
+ print(f"\n--- {path.name} ---")
593
+ print(path.read_text().rstrip())
594
+ return 0
595
+
596
+
597
+ def cmd_logs(args) -> int:
598
+ cfg = cfgmod.load(args.config)
599
+ name = args.agent
600
+ path = (cfg.log_dir / f"{name}.jsonl") if name else (cfg.log_dir / "agentainer.jsonl")
601
+ if not path.is_file():
602
+ print(f"no log yet at {path}")
603
+ return 0
604
+ if args.follow:
605
+ os.execvp("tail", ["tail", "-f", str(path)])
606
+ lines = path.read_text().splitlines()[-args.tail :]
607
+ for line in lines:
608
+ try:
609
+ rec = json.loads(line)
610
+ except json.JSONDecodeError:
611
+ continue
612
+ detail = rec.get("to") or rec.get("from") or rec.get("source") or ""
613
+ print(f"{rec['ts']} {rec['agent']} {rec['kind']} {detail}")
614
+ body = (rec.get("text") or "").strip().replace("\n", "\n ")
615
+ if body:
616
+ print(f" {body}")
617
+ return 0
618
+
619
+
620
+ def cmd_validate(args) -> int:
621
+ cfg = cfgmod.load(args.config)
622
+ for message in cfg.warnings:
623
+ warn(message)
624
+ print(f"config ok: {cfg.path}")
625
+ print(f" swarm: {cfg.name}")
626
+ print(f" root: {cfg.root}")
627
+ print(f" agents: {len(cfg.agents)}")
628
+ for agent in cfg.agents:
629
+ peers = ", ".join(agent.can_talk_to) or "none"
630
+ mp = cfg.mail_paths(agent)
631
+ if agent.workdir.is_dir():
632
+ state = "exists"
633
+ else:
634
+ state = "will be created" if agent.create_workdir else "MISSING"
635
+ print(f"\n - {agent.name} ({agent.type}, capture={agent.capture})")
636
+ print(f" command: {agent.command}")
637
+ print(f" workdir: {agent.workdir} [{state}]")
638
+ print(f" session: {agent.session}")
639
+ print(f" inbox: {mp.inbox}")
640
+ print(f" outbox: {mp.outbox}")
641
+ print(f" talks to: {peers}")
642
+ if args.show_prompts and agent.role:
643
+ body = "\n".join(f" | {l}" for l in agent.role.splitlines())
644
+ print(f" role:\n{body}")
645
+ return 0
646
+
647
+
648
+ def cmd_hook(args) -> int:
649
+ """Turn-completion entry point the installed hooks call.
650
+
651
+ The whole clock runs on this: discover which swarm/agent we are, then sweep
652
+ the agent's outbox (route every message), finish its turn, and release+nudge
653
+ recipients. A hook must never break the agent, so it always returns 0.
654
+ """
655
+ cfg, agent = discover_context(args.config, args.agent)
656
+
657
+ if args.type == "claude":
658
+ try:
659
+ payload = json.load(sys.stdin)
660
+ except (json.JSONDecodeError, ValueError):
661
+ payload = {}
662
+ # Claude sets this when a Stop hook already caused a continuation.
663
+ if payload.get("stop_hook_active"):
664
+ return 0
665
+ # Claude hands us its session id on every turn; that is what --resume wants.
666
+ sessions.record_session(
667
+ cfg, agent, payload.get("session_id"), transcript=payload.get("transcript_path")
668
+ )
669
+ elif args.type == "codex":
670
+ try:
671
+ payload = json.loads(args.payload or "{}")
672
+ except (json.JSONDecodeError, ValueError):
673
+ payload = {}
674
+ if payload.get("type") != "agent-turn-complete":
675
+ return 0
676
+ sessions.record_session(cfg, agent, payload.get("session_id"))
677
+ else:
678
+ # generic: nothing to parse; just sweep + finish the turn.
679
+ pass
680
+
681
+ mail.on_stop(cfg, agent.name)
682
+ return 0
683
+
684
+
685
+ def _watch_tick(cfg, agent, state) -> bool:
686
+ """One watcher poll. Returns True when the pane has been idle long enough to
687
+ call it a completed turn. Updates *state* in place."""
688
+ current = tmux.capture_pane(cfg, agent).splitlines()
689
+ if current != state["previous"]:
690
+ state["previous"] = current
691
+ state["last_change"] = time.monotonic()
692
+ state["dirty"] = True
693
+ return False
694
+ if not state["dirty"]:
695
+ return False
696
+ if (time.monotonic() - state["last_change"]) * 1000 < cfg.pane_idle_ms:
697
+ return False
698
+ return True
699
+
700
+
701
+ def run_watcher(cfg, agent) -> None:
702
+ """Poll an agent's tmux pane; when it stops changing, treat the turn as done.
703
+
704
+ Fallback for pane-capture agents (gemini/hermes) whose CLI cannot call a
705
+ program on turn completion. Exit when the session disappears.
706
+ """
707
+ info(f"watcher started for {agent.name} (session {agent.session})")
708
+ state = {
709
+ "previous": tmux.capture_pane(cfg, agent).splitlines(),
710
+ "last_change": time.monotonic(),
711
+ "dirty": False,
712
+ }
713
+ while tmux.session_exists(agent.session):
714
+ time.sleep(cfg.pane_poll_ms / 1000.0)
715
+ if _watch_tick(cfg, agent, state):
716
+ mail.on_stop(cfg, agent.name)
717
+ state["dirty"] = False
718
+ info(f"watcher for {agent.name}: session gone, exiting")
719
+
720
+
721
+ def cmd_watch(args) -> int:
722
+ cfg = cfgmod.load(args.config)
723
+ agent = cfg.get(args.agent)
724
+ if agent.capture != "pane":
725
+ die(
726
+ f"{agent.name}: capture={agent.capture}, nothing to watch. The pane "
727
+ f"watcher is only for pane-capture agents (gemini/hermes); "
728
+ f"{agent.type} agents detect turn completion via "
729
+ f"{'a Stop hook' if agent.capture == 'hook' else 'no capture at all'}."
730
+ )
731
+ if not tmux.session_exists(agent.session):
732
+ die(f"{agent.name}: session {agent.session!r} is not running (start it with `up`).")
733
+ run_watcher(cfg, agent)
734
+ return 0
735
+
736
+
737
+ def cmd_supervise(args) -> int:
738
+ cfg = cfgmod.load(args.config)
739
+ names = list(args.names) or cfg.names()
740
+ try:
741
+ _import_supervisor().run_supervisor(cfg, names)
742
+ except ImportError:
743
+ die("supervisor module not available")
744
+ return 0
745
+
746
+
747
+ def gen_ui_token() -> str:
748
+ """A random auth token for the UI control plane (no token == no remote bind)."""
749
+ return secrets.token_hex(16)
750
+
751
+
752
+ def cmd_serve(args) -> int:
753
+ """Serve the HTTP control-plane UI (observability + send-from-UI).
754
+
755
+ Binds 127.0.0.1 by default; a token is required for any non-loopback bind
756
+ (enforced inside ``ui.run_server``). The token comes from ``--token``, else
757
+ ``AGENTAINER_UI_TOKEN``, else a freshly generated one printed to stderr.
758
+ """
759
+ cfg = cfgmod.load(args.config)
760
+ token = args.token or os.environ.get("AGENTAINER_UI_TOKEN") or gen_ui_token()
761
+ host = args.host or "127.0.0.1"
762
+ port = args.port or 0
763
+ handle = ui.run_server(cfg, token, host=host, port=port, background=True)
764
+ info(f"UI serving at {handle.url}")
765
+ info(f"UI token: {token}")
766
+ try:
767
+ while True:
768
+ time.sleep(1)
769
+ except KeyboardInterrupt:
770
+ handle.shutdown()
771
+ return 0
772
+
773
+
774
+ # --------------------------------------------------------------------------
775
+ # argument parser
776
+ # --------------------------------------------------------------------------
777
+
778
+
779
+ def build_parser() -> argparse.ArgumentParser:
780
+ parser = argparse.ArgumentParser(
781
+ prog=os.environ.get("AGENTAINER_PROG", "agentainer"),
782
+ description="Run a swarm of coding agents (claude, codex, gemini, hermes) in tmux.",
783
+ )
784
+ parser.add_argument(
785
+ "-v", "--version", action="version", version=f"agentainer {read_version()}",
786
+ help="show the Agentainer version and exit",
787
+ )
788
+ parser.add_argument(
789
+ "-c", "--config", default=default_config(), help="path to the swarm YAML (default: agentainer.yaml)"
790
+ )
791
+ sub = parser.add_subparsers(dest="command", required=True)
792
+
793
+ def add(name, func, help_text):
794
+ p = sub.add_parser(name, help=help_text)
795
+ p.set_defaults(func=func)
796
+ # SUPPRESS so omitting -c here keeps the top-level -c value intact.
797
+ p.add_argument("-c", "--config", default=argparse.SUPPRESS, help=argparse.SUPPRESS)
798
+ return p
799
+
800
+ p_val = add("validate", cmd_validate, "parse the config and print the resolved swarm")
801
+ p_val.add_argument("--show-prompts", action="store_true", help="also print each agent's role")
802
+
803
+ p_up = add("up", cmd_up, "create agent dirs, install hooks, start tmux sessions, send prompts")
804
+ p_up.add_argument("--only", help="comma-separated subset of agents to start")
805
+ p_up.add_argument("--resume", dest="resume", action="store_true", default=None,
806
+ help="reattach each agent to the conversation recorded in sessions.yaml")
807
+ p_up.add_argument("--no-resume", dest="resume", action="store_false",
808
+ help="start fresh conversations (default: resume the recorded ones)")
809
+ p_up.add_argument("--restart", action="store_true", help="kill and recreate existing sessions")
810
+ p_up.add_argument("--no-supervise", action="store_true", help="do not start the liveness supervisor")
811
+
812
+ add("remove-session", cmd_remove_session,
813
+ "delete all Agentainer state (runtime + mailboxes) so the next up starts fresh")
814
+
815
+ p_down = add("down", cmd_down, "kill agent tmux sessions")
816
+ p_down.add_argument("--only", help="comma-separated subset of agents to stop")
817
+
818
+ p_restart = add("restart", cmd_restart, "down + up")
819
+ p_restart.add_argument("--only", help="comma-separated subset of agents")
820
+ p_restart.add_argument("--resume", dest="resume", action="store_true", default=None)
821
+ p_restart.add_argument("--no-resume", dest="resume", action="store_false")
822
+
823
+ add("status", cmd_status, "show which agents are running")
824
+
825
+ p_attach = add("attach", cmd_attach, "attach to an agent's tmux session")
826
+ p_attach.add_argument("agent")
827
+
828
+ p_send = add("send", cmd_send, "send a message to an agent (as user or another agent)")
829
+ p_send.add_argument("--to", required=True, help="recipient agent name")
830
+ p_send.add_argument("--from", dest="sender", help="sender name (default: $AGENTAINER_AGENT or 'user')")
831
+ p_send.add_argument("--file", help="read the message body from a file")
832
+ p_send.add_argument("message", nargs="*", help="message text, or '-' to read stdin")
833
+
834
+ p_user = add("user", cmd_user, "virtual user mailbox: available|away|inbox|send")
835
+ u = p_user.add_subparsers(dest="user_cmd", required=True)
836
+ # Nested subparsers don't inherit the top-level -c, so add it to each.
837
+ for _p in (u.add_parser("available"), u.add_parser("away"), u.add_parser("inbox")):
838
+ _p.add_argument("-c", "--config", default=argparse.SUPPRESS, help=argparse.SUPPRESS)
839
+ p_usend = u.add_parser("send")
840
+ p_usend.add_argument("-c", "--config", default=argparse.SUPPRESS, help=argparse.SUPPRESS)
841
+ p_usend.add_argument("--to", required=True)
842
+ p_usend.add_argument("--file")
843
+ p_usend.add_argument("message", nargs="*")
844
+
845
+ p_sessions = add("sessions", cmd_sessions, "show each agent's recorded conversation id")
846
+ p_sessions.add_argument("--raw", action="store_true", help="print sessions.yaml verbatim")
847
+
848
+ p_queue = add("queue", cmd_queue, "show (or clear) the messages waiting for an agent")
849
+ p_queue.add_argument("agent")
850
+ p_queue.add_argument("--clear", action="store_true", help="discard everything queued")
851
+
852
+ p_idle = add("idle", cmd_idle, "force an agent back to idle if a capture never fired")
853
+ p_idle.add_argument("agent")
854
+ p_idle.add_argument("--no-drain", action="store_true", help="do not process the read/ folder")
855
+
856
+ p_inbox = add("inbox", cmd_inbox, "print the current inbox message for an agent")
857
+ p_inbox.add_argument("agent", nargs="?")
858
+ p_inbox.add_argument("-n", "--tail", type=int, default=5)
859
+
860
+ p_logs = add("logs", cmd_logs, "print the swarm event log")
861
+ p_logs.add_argument("agent", nargs="?", help="agent name, or omit for the whole swarm")
862
+ p_logs.add_argument("-n", "--tail", type=int, default=20)
863
+ p_logs.add_argument("-f", "--follow", action="store_true")
864
+
865
+ p_hook = add("hook", cmd_hook, "internal: called by an agent's completion hook")
866
+ p_hook.add_argument("type", choices=["claude", "codex", "generic"])
867
+ p_hook.add_argument("payload", nargs="?", help="JSON payload (codex passes it as argv)")
868
+ p_hook.add_argument("--agent", help="override the detected agent name")
869
+
870
+ p_watch = add("watch", cmd_watch, "internal: poll an agent's tmux pane for completed turns")
871
+ p_watch.add_argument("agent")
872
+
873
+ p_sup = add("supervise", cmd_supervise, "internal: background liveness watchdog")
874
+ p_sup.add_argument("names", nargs="*", help="agents to watch (default: all)")
875
+
876
+ p_serve = add("serve", cmd_serve, "serve the HTTP control-plane UI (observability)")
877
+ p_serve.add_argument("--host", default=None, help="bind host (default: 127.0.0.1)")
878
+ p_serve.add_argument("--port", type=int, default=0, help="port (default: auto)")
879
+ p_serve.add_argument("--token", default=None, help="auth token (default: env or random)")
880
+
881
+ # P4: dynamic reconcile -- add / remove / edit agents at runtime.
882
+ p_add = add("add", reconcile.cmd_add, "add an agent to the config and bring it up")
883
+ p_add.add_argument("name", help="new agent name")
884
+ p_add.add_argument("--type", required=True, help="agent type (claude|codex|gemini|hermes)")
885
+ p_add.add_argument("--command", required=True, help="shell command that launches the agent CLI")
886
+ p_add.add_argument("--can-talk-to", default="user", help="comma-separated ACL, or '*' for all")
887
+ p_add.add_argument("--role", default="", help="standing role / first prompt")
888
+ p_add.add_argument("--workdir", default=None, help="working directory (default: <root>/<name>)")
889
+
890
+ p_remove = add("remove", reconcile.cmd_remove, "remove an agent from the config and stop it")
891
+ p_remove.add_argument("name", help="agent to remove")
892
+
893
+ p_edit = add("edit", reconcile.cmd_edit, "edit an agent's fields in the config and reconcile")
894
+ p_edit.add_argument("name", help="agent to edit")
895
+ p_edit.add_argument("-s", "--set", action="append", help="key=value to set (repeatable)")
896
+
897
+ add("reconcile", reconcile.cmd_reconcile, "start missing agents / stop extra sessions to match config")
898
+
899
+ return parser
900
+
901
+
902
+ # --------------------------------------------------------------------------
903
+ # entry point
904
+ # --------------------------------------------------------------------------
905
+
906
+
907
+ def main(argv: list[str] | None = None) -> int:
908
+ argv = list(sys.argv[1:] if argv is None else argv)
909
+
910
+ # `agentainer some.yaml up` and `agentainer ./x.yaml` both mean "up with this config".
911
+ if argv and not argv[0].startswith("-") and argv[0].endswith((".yaml", ".yml")):
912
+ path = argv.pop(0)
913
+ argv = [*(argv or ["up"]), "-c", path]
914
+
915
+ args = build_parser().parse_args(argv)
916
+ try:
917
+ return args.func(args)
918
+ except ConfigError as exc:
919
+ die(str(exc))
920
+ except tmux.SwarmError as exc:
921
+ die(str(exc))
922
+ except subprocess.CalledProcessError as exc:
923
+ die(f"command failed: {exc}")
924
+ except KeyboardInterrupt: # pragma: no cover - only triggered by a real SIGINT
925
+ return 130
926
+
927
+
928
+ if __name__ == "__main__": # pragma: no cover - exercised by running the module as a script
929
+ sys.exit(main())