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.
- package/README.md +248 -677
- package/agentainer +16 -18
- package/agentainer.example.yaml +86 -0
- package/bin/agentainer.js +9 -8
- package/examples/brainstorm.yaml +27 -128
- package/examples/bug-hunt.yaml +51 -96
- package/examples/code-review.yaml +73 -0
- package/examples/debate.yaml +16 -90
- package/examples/incident-response.yaml +52 -109
- package/examples/localization.yaml +56 -123
- package/examples/quickstart.yaml +48 -0
- package/examples/research.yaml +25 -0
- package/examples/software-company.yaml +71 -128
- package/examples/tdd-pingpong.yaml +36 -68
- package/examples/writers-room.yaml +49 -111
- package/hooks/claude_stop.sh +5 -3
- package/hooks/codex_notify.sh +4 -3
- package/lib/cli.py +929 -0
- package/lib/config.py +247 -305
- package/lib/hooks.py +246 -0
- package/lib/lock.py +75 -0
- package/lib/log.py +64 -0
- package/lib/mail.py +634 -0
- package/lib/minyaml.py +1 -39
- package/lib/reconcile.py +473 -0
- package/lib/sessions.py +223 -0
- package/lib/supervisor.py +216 -0
- package/lib/telegram.py +372 -0
- package/lib/tmux.py +355 -0
- package/lib/turn.py +159 -0
- package/lib/ui.py +1020 -0
- package/llms.txt +145 -429
- package/package.json +9 -7
- package/scripts/check-deps.js +18 -61
- package/ui/app.js +869 -0
- package/ui/index.html +348 -0
- package/agents.example.yaml +0 -257
- package/examples/code-review-broadcast.yaml +0 -109
- package/examples/existing-repo.yaml +0 -74
- package/examples/multi-language-broadcast.yaml +0 -127
- package/examples/ping-pong.yaml +0 -89
- package/examples/red-team.yaml +0 -117
- package/examples/research-swarm.yaml +0 -129
- 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())
|