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
@@ -0,0 +1,223 @@
1
+ """Session / resume machinery for the Agentainer orchestrator.
2
+
3
+ A faithful port of v1's ``lib/swarm.py`` session helpers (``read_sessions``,
4
+ ``write_sessions``, ``record_session``, ``codex_session``) and the lifecycle
5
+ helpers ``session_env`` / ``resume_command``, adapted to the v2 branding:
6
+
7
+ * ``SWARM_HOME`` -> ``AGENTAINER_HOME``, ``SWARM_ROOT`` -> ``AGENTAINER_ROOT``,
8
+ etc. on every env var name and log string;
9
+ * "swarm" -> "agentainer" in every message;
10
+ * the session file moved to ``cfg.sessions_file`` (``.agentainer/sessions.yaml``),
11
+ which ``config.py`` already exposes.
12
+
13
+ The YAML session file is the bridge that lets ``agentainer up`` (resume is the
14
+ default) reattach each agent to its own conversation after a restart. It is
15
+ written atomically
16
+ because the turn-completion hooks write to it concurrently.
17
+
18
+ Zero runtime dependencies: stdlib + our own ``config`` / ``minyaml`` / ``tmux``
19
+ helpers only. PyYAML is used when importable, otherwise the bundled ``minyaml``
20
+ subset parser (imported through ``config.parse_yaml`` so the two paths stay in
21
+ parity). The hand-written ``yaml_dump`` keeps the no-PyYAML path alive for writes.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import os
28
+ import sys
29
+ from datetime import datetime, timezone
30
+ from pathlib import Path
31
+
32
+ from config import Agent, SwarmConfig, parse_yaml
33
+ from tmux import file_lock
34
+
35
+ # Repo root: AGENTAINER_HOME overrides, else this file's grandparent (lib/..).
36
+ AGENTAINER_HOME = Path(
37
+ os.environ.get("AGENTAINER_HOME") or Path(__file__).resolve().parent.parent
38
+ )
39
+
40
+
41
+ # --------------------------------------------------------------------------
42
+ # small utilities
43
+ # --------------------------------------------------------------------------
44
+
45
+
46
+ def now_iso() -> str:
47
+ """Current UTC time as an ISO-8601 string with second precision."""
48
+ return datetime.now(timezone.utc).isoformat(timespec="seconds")
49
+
50
+
51
+ def info(msg: str) -> None:
52
+ print(f"\033[36m::\033[0m {msg}", file=sys.stderr)
53
+
54
+
55
+ def warn(msg: str) -> None:
56
+ print(f"\033[33m!!\033[0m {msg}", file=sys.stderr)
57
+
58
+
59
+ # --------------------------------------------------------------------------
60
+ # sessions.yaml -- the conversation id of every agent, so `up --resume` works
61
+ # --------------------------------------------------------------------------
62
+
63
+
64
+ def yaml_scalar(value) -> str:
65
+ if value is None:
66
+ return "null"
67
+ if isinstance(value, bool):
68
+ return "true" if value else "false"
69
+ if isinstance(value, (int, float)):
70
+ return str(value)
71
+ text = str(value).replace("\\", "\\\\").replace('"', '\\"')
72
+ return f'"{text}"'
73
+
74
+
75
+ def yaml_dump(data: dict, indent: int = 0) -> str:
76
+ """Emit the small subset we need. Written by hand so PyYAML stays optional."""
77
+ pad = " " * indent
78
+ out = []
79
+ for key, value in data.items():
80
+ if isinstance(value, dict):
81
+ out.append(f"{pad}{key}:")
82
+ out.append(yaml_dump(value, indent + 2) if value else f"{pad} {{}}")
83
+ else:
84
+ out.append(f"{pad}{key}: {yaml_scalar(value)}")
85
+ return "\n".join(out)
86
+
87
+
88
+ def read_sessions(cfg: SwarmConfig) -> dict:
89
+ """The agents block of sessions.yaml, or {} if it is missing or unreadable."""
90
+ try:
91
+ data = parse_yaml(cfg.sessions_file.read_text())
92
+ except OSError:
93
+ return {}
94
+ except Exception as exc: # noqa: BLE001 - a corrupt file must not stop the swarm
95
+ warn(f"could not parse {cfg.sessions_file}: {exc}")
96
+ return {}
97
+ if not isinstance(data, dict):
98
+ return {}
99
+ return data.get("agents") or {}
100
+
101
+
102
+ def write_sessions(cfg: SwarmConfig, agents: dict) -> None:
103
+ cfg.runtime.mkdir(parents=True, exist_ok=True)
104
+ header = (
105
+ "# Agentainer session state -- written automatically as agents work.\n"
106
+ "# `agentainer up` reads this to reattach each agent to its own\n"
107
+ "# conversation after a restart (resume is the default; `remove-session`\n"
108
+ "# wipes it for a clean start). Safe to delete; you then start fresh.\n"
109
+ )
110
+ body = yaml_dump(
111
+ {
112
+ "swarm": cfg.name,
113
+ "config": str(cfg.path),
114
+ "updated_at": now_iso(),
115
+ "agents": agents or {},
116
+ }
117
+ )
118
+ tmp = cfg.sessions_file.with_suffix(".yaml.tmp")
119
+ tmp.write_text(header + body + "\n")
120
+ os.replace(tmp, cfg.sessions_file) # atomic: hooks write this concurrently
121
+
122
+
123
+ def record_session(cfg: SwarmConfig, agent: Agent, session_id, **fields) -> None:
124
+ """Merge this agent's conversation id into sessions.yaml, under a lock."""
125
+ if not session_id:
126
+ return
127
+ with file_lock(cfg, "sessions", "lock"):
128
+ agents = read_sessions(cfg)
129
+ entry = agents.get(agent.name) or {}
130
+ if entry.get("session_id") == session_id:
131
+ return # unchanged: do not rewrite the file after every single turn
132
+ entry.update({k: v for k, v in fields.items() if v})
133
+ entry["session_id"] = session_id
134
+ entry["type"] = agent.type
135
+ entry["workdir"] = str(agent.workdir)
136
+ entry["updated_at"] = now_iso()
137
+ agents[agent.name] = entry
138
+ write_sessions(cfg, agents)
139
+ info(f"{agent.name}: recorded conversation {session_id}")
140
+
141
+
142
+ def codex_session(agent: Agent) -> tuple[str | None, str | None]:
143
+ """Find the id of the codex conversation running in this agent's CODEX_HOME.
144
+
145
+ Codex does not hand its session id to the notify program, but it writes one
146
+ rollout file per session under CODEX_HOME/sessions, and the newest of those is
147
+ the conversation currently in progress.
148
+ """
149
+ sessions = agent.workdir / ".codex" / "sessions"
150
+ if not sessions.is_dir():
151
+ return None, None
152
+
153
+ rollouts = sorted(sessions.rglob("rollout-*.jsonl"), key=lambda p: p.stat().st_mtime)
154
+ if not rollouts:
155
+ return None, None
156
+
157
+ newest = rollouts[-1]
158
+ try:
159
+ with newest.open() as fh:
160
+ first = fh.readline()
161
+ record = json.loads(first)
162
+ if record.get("type") == "session_meta":
163
+ payload = record.get("payload", {})
164
+ return payload.get("session_id") or payload.get("id"), str(newest)
165
+ except (OSError, json.JSONDecodeError):
166
+ pass
167
+ return None, str(newest)
168
+
169
+
170
+ # --------------------------------------------------------------------------
171
+ # lifecycle
172
+ # --------------------------------------------------------------------------
173
+
174
+
175
+ def session_env(cfg: SwarmConfig, agent: Agent, extra: dict[str, str]) -> dict[str, str]:
176
+ """The environment given to an agent's tmux session.
177
+
178
+ Always includes the authoritative Agentainer locations (``AGENTAINER_HOME``,
179
+ ``AGENTAINER_ROOT`` == ``cfg.root``, the config path, the swarm name, and the
180
+ agent's own name/session/peers), then the agent's own ``env`` block, then any
181
+ caller-supplied ``extra`` (e.g. capture-hook vars from ``hooks.install_capture``
182
+ when the agent is launched).
183
+ """
184
+ env = {
185
+ "AGENTAINER_HOME": str(AGENTAINER_HOME),
186
+ "AGENTAINER_CONFIG": str(cfg.path),
187
+ "AGENTAINER_ROOT": str(cfg.root),
188
+ "AGENTAINER_NAME": cfg.name,
189
+ "AGENTAINER_AGENT": agent.name,
190
+ "AGENTAINER_SESSION": agent.session,
191
+ "AGENTAINER_PEERS": ",".join(agent.can_talk_to),
192
+ }
193
+ env.update(agent.env)
194
+ env.update(extra)
195
+ return env
196
+
197
+
198
+ def resume_command(cfg: SwarmConfig, agent: Agent, session_id: str) -> str | None:
199
+ """The command that reattaches *agent* to conversation *session_id*.
200
+
201
+ ``resume_command`` (an exact recipe) wins, because a command like
202
+ ``bash -ic chy3`` invokes the CLI through an alias and flags cannot simply be
203
+ appended to it. Failing that, ``resume_args`` is formatted and appended to the
204
+ agent's command. Agents whose type has no recoverable session (gemini/hermes --
205
+ a session id cannot be scraped from a pane) have no recipe, so we warn and start
206
+ a fresh conversation; a malformed recipe is treated the same way.
207
+ """
208
+ try:
209
+ if agent.resume_command:
210
+ return agent.resume_command.format(session_id=session_id, command=agent.command)
211
+ if agent.resume_args:
212
+ return f"{agent.command} {agent.resume_args.format(session_id=session_id)}"
213
+ except (KeyError, IndexError, ValueError) as exc:
214
+ warn(
215
+ f"{agent.name}: resume recipe is malformed ({exc}); "
216
+ "starting a fresh conversation"
217
+ )
218
+ return None
219
+ warn(
220
+ f"{agent.name}: type {agent.type} has no resume recipe; "
221
+ "starting a fresh conversation"
222
+ )
223
+ return None
@@ -0,0 +1,216 @@
1
+ #!/usr/bin/env python3
2
+ """Agentainer -- the liveness supervisor (the orchestrator's heartbeat).
3
+
4
+ v1 shipped without this once and had to add it: the swarm is otherwise purely
5
+ event-driven -- progress only happens when an agent's capture fires (hook/notify)
6
+ or a human sends mail. If that event never arrives (a crashed CLI, a killed tmux
7
+ session, a capture that never fired, a "silent but alive" agent whose completion
8
+ we cannot trust), nothing wakes the loop and an agent sits on unread mail forever.
9
+
10
+ This module is the periodic heartbeat that reconciles those failure modes:
11
+
12
+ * STALE-BUSY -- the turn-completion signal (delivered > completed) is older
13
+ than ``busy_timeout_ms``; the hook/notify never fired, so we mark the turn
14
+ finished and let the queue advance.
15
+ * DEAD -- the tmux session is gone; we warn once (not every tick) and
16
+ reconcile the turn so the agent is not "busy" forever.
17
+ * SILENT-BUT-ALIVE -- the session is up but ``capture == "none"`` (v2 health
18
+ probe), so we have no reliable turn-completion signal; we surface it once.
19
+ * IDLE -- only then do we process read receipts, release the next queued
20
+ message (one-at-a-time) + nudge, or fire a periodic ping when the inbox would
21
+ be empty. Real mail always takes priority over pings.
22
+
23
+ Ported from v1 ``lib/swarm.py`` (start_supervisor / stop_supervisor /
24
+ supervisor_alive / supervise_once / run_supervisor) and adapted to drive the v2
25
+ file-based mailroom. Zero runtime dependencies: stdlib + bundled lib/ only.
26
+
27
+ Branding: "swarm" is retired -- it's Agentainer everywhere (decision D21).
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ import os
33
+ import subprocess
34
+ import sys
35
+ import time
36
+ from pathlib import Path
37
+
38
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
39
+
40
+ import config as cfgmod # noqa: E402
41
+ from config import SwarmConfig # noqa: E402
42
+
43
+ import log # noqa: E402
44
+ import mail # noqa: E402
45
+ import tmux # noqa: E402
46
+ import turn # noqa: E402
47
+ from tmux import sleep_ms # noqa: E402
48
+
49
+
50
+ # PID file lives under the run dir (v1 used run_dir/supervisor.pid). Branded path.
51
+ SUPERVISOR_PID = "supervisor.pid"
52
+
53
+ # The supervise subcommand is the internal plumbing entry the CLI exposes; we keep
54
+ # its surface minimal/stable. Launched via the project's launcher so the same
55
+ # install resolution (AGENTAINER_HOME) the `./agentainer` script uses applies.
56
+ _CLI = Path(__file__).resolve().parent / "cli.py"
57
+
58
+
59
+ # --------------------------------------------------------------------------
60
+ # process management: launch / stop / probe the background supervisor
61
+ # --------------------------------------------------------------------------
62
+
63
+
64
+ def start_supervisor(cfg: SwarmConfig, names: list[str]) -> None:
65
+ """Launch the background liveness supervisor for *names* (the agents we started)."""
66
+ cfg.run_dir.mkdir(parents=True, exist_ok=True)
67
+ cfg.log_dir.mkdir(parents=True, exist_ok=True)
68
+ logfile = (cfg.log_dir / "supervisor.log").open("a")
69
+ # Pass the config via AGENTAINER_CONFIG (mirrors v1's SWARM_CONFIG) and export
70
+ # AGENTAINER_HOME so the spawned launcher resolves the install and lib/ path.
71
+ env = dict(os.environ)
72
+ env["AGENTAINER_HOME"] = str(Path(__file__).resolve().parent.parent)
73
+ env["AGENTAINER_CONFIG"] = str(cfg.path)
74
+ proc = subprocess.Popen(
75
+ [sys.executable, str(_CLI), "supervise", *names],
76
+ stdout=logfile,
77
+ stderr=subprocess.STDOUT,
78
+ stdin=subprocess.DEVNULL,
79
+ env=env,
80
+ start_new_session=True,
81
+ )
82
+ (cfg.run_dir / SUPERVISOR_PID).write_text(str(proc.pid))
83
+
84
+
85
+ def stop_supervisor(cfg: SwarmConfig) -> None:
86
+ pid_file = cfg.run_dir / SUPERVISOR_PID
87
+ if not pid_file.is_file():
88
+ return
89
+ try:
90
+ pid = int(pid_file.read_text().strip())
91
+ os.kill(pid, 15)
92
+ except (OSError, ValueError):
93
+ pass
94
+ pid_file.unlink(missing_ok=True)
95
+
96
+
97
+ def supervisor_alive(cfg: SwarmConfig) -> bool:
98
+ pid_file = cfg.run_dir / SUPERVISOR_PID
99
+ if not pid_file.is_file():
100
+ return False
101
+ try:
102
+ os.kill(int(pid_file.read_text().strip()), 0)
103
+ return True
104
+ except (OSError, ValueError):
105
+ return False
106
+
107
+
108
+ # --------------------------------------------------------------------------
109
+ # the heartbeat
110
+ # --------------------------------------------------------------------------
111
+
112
+
113
+ def _seen_silent_set(cfg: SwarmConfig) -> set[str]:
114
+ """Return the per-config set of agents currently known silent-but-alive.
115
+
116
+ Stored on the config object itself (a process-global "state flag") so the
117
+ fixed ``supervise_once(cfg, names, seen_dead)`` signature need not grow a
118
+ parameter, and the transition (log once, not every tick) survives across
119
+ ticks. A fresh config starts with an empty set.
120
+ """
121
+ s = getattr(cfg, "_seen_silent", None)
122
+ if s is None:
123
+ s = set()
124
+ cfg._seen_silent = s
125
+ return s
126
+
127
+
128
+ def supervise_once(cfg: SwarmConfig, names: list[str], seen_dead: set[str]) -> None:
129
+ """One reconciliation pass over the watched agents.
130
+
131
+ Split out of the loop so it can be unit-tested without the timer. Reconciles
132
+ the failure modes the event-driven design otherwise never notices (see the
133
+ module docstring). ``seen_dead`` is an in-memory set (the supervisor is one
134
+ long-lived process) so a dead session is warned about once, not every tick.
135
+ """
136
+ for name in names:
137
+ agent = cfg.get(name)
138
+
139
+ # (a) STALE-BUSY. v2's ``turn.busy_info`` already fails open (warns and
140
+ # returns None) when the turn is older than busy_timeout_ms, so read the
141
+ # authoritative turn state directly to detect + recover the wedged agent
142
+ # and record it in the event log.
143
+ state = turn.turn_state(cfg, name)
144
+ if state.get("delivered", 0) > state.get("completed", 0):
145
+ age_ms = (time.time() - state.get("since", 0)) * 1000
146
+ if age_ms > cfg.busy_timeout_ms:
147
+ log.log_event(cfg, name, "stale-busy", age_s=int(age_ms / 1000))
148
+ turn.mark_turn_finished(cfg, name)
149
+
150
+ # (b) DEAD session. Don't try to deliver into a pane that no longer
151
+ # exists; reconcile the turn and warn once per transition.
152
+ if not tmux.session_exists(agent.session):
153
+ if name not in seen_dead:
154
+ seen_dead.add(name)
155
+ log.log_event(cfg, name, "dead")
156
+ turn.mark_turn_finished(cfg, name)
157
+ continue
158
+ seen_dead.discard(name)
159
+
160
+ # (c) SILENT-BUT-ALIVE. The v2 health probe surfaces an agent that is up
161
+ # in tmux but whose turn-completion signal the orchestrator cannot trust
162
+ # (capture == "none"). Log the transition once; clear it when it resolves.
163
+ hp = turn.health_probe(cfg, agent)
164
+ if hp["silent_but_alive"]:
165
+ silent = _seen_silent_set(cfg)
166
+ if name not in silent:
167
+ silent.add(name)
168
+ log.log_event(cfg, name, "silent-but-alive")
169
+ else:
170
+ _seen_silent_set(cfg).discard(name)
171
+
172
+ # (d) IDLE: the only time we push work. Read receipts first, then deliver
173
+ # real queued mail one-at-a-time (nudge), else a periodic ping -- real
174
+ # mail always takes priority over pings.
175
+ if turn.busy_info(cfg, agent) is None:
176
+ mail.process_read_folder(cfg, name)
177
+ if not mail.release_next(cfg, name):
178
+ mail.maybe_ping(cfg, name)
179
+ else:
180
+ mail.nudge(cfg, name)
181
+
182
+
183
+ def _emit(cfg: SwarmConfig, kind: str, msg: str) -> None:
184
+ """Log a supervisor lifecycle event and echo it to stderr for the operator."""
185
+ log.log_event(cfg, "supervisor", kind)
186
+ print(f"supervisor: {msg}", file=sys.stderr)
187
+
188
+
189
+ def run_supervisor(cfg: SwarmConfig, names: list[str]) -> None:
190
+ """Background loop: the heartbeat that keeps one silent agent from wedging the swarm.
191
+
192
+ The swarm is otherwise purely event-driven -- progress only happens when an
193
+ agent's capture fires (hook/pane) or a human sends a message. If that event
194
+ never arrives, nothing wakes the loop. This polls on a timer and reconciles
195
+ stale/dead/silent state. It self-exits once every watched session is gone
196
+ (after `down`), so it does not run forever.
197
+ """
198
+ _emit(cfg, "supervisor-start", "started")
199
+ seen_dead: set[str] = set()
200
+ try:
201
+ while True:
202
+ sleep_ms(cfg.supervise_interval_ms)
203
+ if not any(tmux.session_exists(cfg.get(n).session) for n in names):
204
+ _emit(cfg, "no-watched-sessions", "no watched sessions remain, exiting")
205
+ return
206
+ try:
207
+ supervise_once(cfg, names, seen_dead)
208
+ except Exception as exc: # noqa: BLE001 - one agent's failure must not kill the heartbeat
209
+ # The supervisor exists precisely so one wedged/silent agent
210
+ # can't take down the swarm. A lost release_next race or a
211
+ # bad pane read must be logged and survived, never propagated
212
+ # into the process that keeps the whole swarm alive.
213
+ log.log_event(cfg, "supervisor", "tick-error", error=str(exc))
214
+ print(f"supervisor: tick error (continuing): {exc}", file=sys.stderr)
215
+ except KeyboardInterrupt:
216
+ _emit(cfg, "supervisor-interrupted", "interrupted, exiting")