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/hooks.py
ADDED
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""Turn-completion wiring for Agentainer agents.
|
|
2
|
+
|
|
3
|
+
Ports the proven v1 hook plumbing (trust-modal pre-trust + Stop/notify hook
|
|
4
|
+
installation + capture config) and generalizes it behind a single per-type
|
|
5
|
+
dispatch, ``install_turn_detection`` (plan §13 / D18). Every function here is
|
|
6
|
+
deterministic and dependency-free: the model never has to install a hook
|
|
7
|
+
itself, and the hook commands are written with absolute paths resolved from
|
|
8
|
+
the repo root so they work regardless of the agent's cwd.
|
|
9
|
+
|
|
10
|
+
The only capability the model needs is read/write files. Everything about
|
|
11
|
+
routing, ACL, and turn-detection is orchestrator code.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import shlex
|
|
18
|
+
import shutil
|
|
19
|
+
import sys
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
23
|
+
|
|
24
|
+
from config import Agent, SwarmConfig # noqa: E402
|
|
25
|
+
|
|
26
|
+
# Repo root: AGENTAINER_HOME overrides, else this file's grandparent (lib/..).
|
|
27
|
+
AGENTAINER_HOME = Path(
|
|
28
|
+
os.environ.get("AGENTAINER_HOME") or Path(__file__).resolve().parent.parent
|
|
29
|
+
)
|
|
30
|
+
HOOKS_DIR = AGENTAINER_HOME / "hooks"
|
|
31
|
+
|
|
32
|
+
# Agent types whose CLI can invoke an external program when a turn completes.
|
|
33
|
+
HOOK_CAPABLE = ("claude", "codex")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
# --------------------------------------------------------------------------
|
|
37
|
+
# small utilities
|
|
38
|
+
# --------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def info(msg: str) -> None:
|
|
42
|
+
print(f"\033[36m::\033[0m {msg}", file=sys.stderr)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def warn(msg: str) -> None:
|
|
46
|
+
print(f"\033[33m!!\033[0m {msg}", file=sys.stderr)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# --------------------------------------------------------------------------
|
|
50
|
+
# capture: hook installation
|
|
51
|
+
# --------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def pretrust_claude_dir(agent: Agent) -> None:
|
|
55
|
+
"""Mark the agent's workdir as trusted in ~/.claude.json.
|
|
56
|
+
|
|
57
|
+
Claude Code asks "Do you trust the files in this folder?" the first time it
|
|
58
|
+
runs anywhere new -- even under --dangerously-skip-permissions -- and that
|
|
59
|
+
modal swallows the first prompt (Enter answers the dialog). Codex gets the
|
|
60
|
+
same treatment via its config.toml; this is the claude equivalent.
|
|
61
|
+
"""
|
|
62
|
+
path = Path(os.path.expanduser("~")) / ".claude.json"
|
|
63
|
+
if not path.is_file():
|
|
64
|
+
return # claude has never run here; it will create the file itself
|
|
65
|
+
|
|
66
|
+
try:
|
|
67
|
+
data = json.loads(path.read_text())
|
|
68
|
+
except (OSError, json.JSONDecodeError):
|
|
69
|
+
warn(f"{agent.name}: could not read ~/.claude.json; the trust dialog may appear")
|
|
70
|
+
return
|
|
71
|
+
|
|
72
|
+
projects = data.setdefault("projects", {})
|
|
73
|
+
entry = projects.setdefault(str(agent.workdir), {})
|
|
74
|
+
if entry.get("hasTrustDialogAccepted"):
|
|
75
|
+
return
|
|
76
|
+
|
|
77
|
+
entry["hasTrustDialogAccepted"] = True
|
|
78
|
+
entry.setdefault("projectOnboardingSeenCount", 1)
|
|
79
|
+
|
|
80
|
+
# Write atomically: a running claude may be reading this file.
|
|
81
|
+
tmp = path.with_suffix(".json.agentainer-tmp")
|
|
82
|
+
try:
|
|
83
|
+
tmp.write_text(json.dumps(data, indent=2))
|
|
84
|
+
os.replace(tmp, path)
|
|
85
|
+
except OSError as exc:
|
|
86
|
+
warn(f"{agent.name}: could not pre-trust {agent.workdir}: {exc}")
|
|
87
|
+
tmp.unlink(missing_ok=True)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def install_claude_hook(agent: Agent) -> None:
|
|
91
|
+
pretrust_claude_dir(agent)
|
|
92
|
+
settings_path = agent.workdir / ".claude" / "settings.json"
|
|
93
|
+
settings_path.parent.mkdir(parents=True, exist_ok=True)
|
|
94
|
+
|
|
95
|
+
settings: dict = {}
|
|
96
|
+
if settings_path.is_file():
|
|
97
|
+
try:
|
|
98
|
+
settings = json.loads(settings_path.read_text())
|
|
99
|
+
except json.JSONDecodeError:
|
|
100
|
+
warn(f"{settings_path} is not valid JSON; overwriting")
|
|
101
|
+
|
|
102
|
+
hook_cmd = str(HOOKS_DIR / "claude_stop.sh")
|
|
103
|
+
# No "matcher" key: Stop is not a tool event, and supplying one can stop the
|
|
104
|
+
# interactive TUI from ever running the hook.
|
|
105
|
+
entry = {"hooks": [{"type": "command", "command": hook_cmd}]}
|
|
106
|
+
hooks = settings.setdefault("hooks", {})
|
|
107
|
+
stop_hooks = [
|
|
108
|
+
h
|
|
109
|
+
for h in hooks.get("Stop", [])
|
|
110
|
+
if hook_cmd not in json.dumps(h) # drop our own stale entry, keep the user's
|
|
111
|
+
]
|
|
112
|
+
stop_hooks.append(entry)
|
|
113
|
+
hooks["Stop"] = stop_hooks
|
|
114
|
+
settings_path.write_text(json.dumps(settings, indent=2) + "\n")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def valid_toml(text: str) -> bool:
|
|
118
|
+
try:
|
|
119
|
+
import tomllib
|
|
120
|
+
except ImportError: # Python < 3.11: cannot check, assume the caller is right
|
|
121
|
+
return True
|
|
122
|
+
try:
|
|
123
|
+
tomllib.loads(text)
|
|
124
|
+
return True
|
|
125
|
+
except tomllib.TOMLDecodeError:
|
|
126
|
+
return False
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def install_codex_hook(agent: Agent) -> Path:
|
|
130
|
+
"""Give codex a private CODEX_HOME with a `notify` program wired up."""
|
|
131
|
+
codex_home = agent.workdir / ".codex"
|
|
132
|
+
codex_home.mkdir(parents=True, exist_ok=True)
|
|
133
|
+
|
|
134
|
+
# Carry over the user's real credentials + settings, so the agent is logged in.
|
|
135
|
+
user_home = Path(os.path.expanduser("~")) / ".codex"
|
|
136
|
+
base = ""
|
|
137
|
+
if user_home.is_dir() and user_home.resolve() != codex_home.resolve():
|
|
138
|
+
for name in ("auth.json",):
|
|
139
|
+
src, dst = user_home / name, codex_home / name
|
|
140
|
+
if src.is_file() and not dst.exists():
|
|
141
|
+
try:
|
|
142
|
+
dst.symlink_to(src)
|
|
143
|
+
except OSError:
|
|
144
|
+
shutil.copy2(src, dst)
|
|
145
|
+
user_cfg = user_home / "config.toml"
|
|
146
|
+
if user_cfg.is_file():
|
|
147
|
+
base = "\n".join(
|
|
148
|
+
line
|
|
149
|
+
for line in user_cfg.read_text().splitlines()
|
|
150
|
+
if not re.match(r"\s*notify\s*=", line)
|
|
151
|
+
).strip()
|
|
152
|
+
|
|
153
|
+
notify = json.dumps(str(HOOKS_DIR / "codex_notify.sh"))
|
|
154
|
+
# Without this table codex opens a "do you trust this directory?" modal on
|
|
155
|
+
# first run in a fresh folder, and that modal swallows the first prompt.
|
|
156
|
+
trust = f"[projects.{json.dumps(str(agent.workdir))}]"
|
|
157
|
+
|
|
158
|
+
# TOML is order-sensitive: a bare key written after a [table] header belongs
|
|
159
|
+
# to that table. `notify` must therefore come before anything else, or codex
|
|
160
|
+
# reads it as projects.<dir>.notify and never calls it.
|
|
161
|
+
chunks = [
|
|
162
|
+
"# installed by Agentainer -- fires when codex finishes a turn.",
|
|
163
|
+
"# Keep `notify` above every [table] header: TOML is order-sensitive.",
|
|
164
|
+
f"notify = [{notify}]",
|
|
165
|
+
"",
|
|
166
|
+
]
|
|
167
|
+
if base:
|
|
168
|
+
chunks += [base, ""]
|
|
169
|
+
if trust not in base: # the user's config may already trust this directory
|
|
170
|
+
chunks += ["# pre-trust the workdir so no modal eats the first prompt", trust,
|
|
171
|
+
'trust_level = "trusted"', ""]
|
|
172
|
+
|
|
173
|
+
body = "\n".join(chunks)
|
|
174
|
+
if not valid_toml(body):
|
|
175
|
+
warn(
|
|
176
|
+
f"{agent.name}: ~/.codex/config.toml could not be merged cleanly "
|
|
177
|
+
"(invalid TOML); writing a minimal config instead"
|
|
178
|
+
)
|
|
179
|
+
body = "\n".join(
|
|
180
|
+
[f"notify = [{notify}]", "", trust, 'trust_level = "trusted"', ""]
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
(codex_home / "config.toml").write_text(body)
|
|
184
|
+
return codex_home
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def install_capture(agent: Agent) -> dict[str, str]:
|
|
188
|
+
"""Install turn-completion capture. Returns extra env vars for the session.
|
|
189
|
+
|
|
190
|
+
Mirrors v1: only does work when ``capture == "hook"``. Claude gets a Stop
|
|
191
|
+
hook, Codex gets a CODEX_HOME with a notify program, and any other type
|
|
192
|
+
falls back to pane polling (and has its ``capture`` downgraded so callers
|
|
193
|
+
stop expecting a hook signal).
|
|
194
|
+
"""
|
|
195
|
+
env: dict[str, str] = {}
|
|
196
|
+
if agent.capture != "hook":
|
|
197
|
+
return env
|
|
198
|
+
|
|
199
|
+
if agent.type == "claude":
|
|
200
|
+
install_claude_hook(agent)
|
|
201
|
+
elif agent.type == "codex":
|
|
202
|
+
env["CODEX_HOME"] = str(install_codex_hook(agent))
|
|
203
|
+
else:
|
|
204
|
+
warn(
|
|
205
|
+
f"agent {agent.name!r}: type {agent.type!r} has no known completion hook "
|
|
206
|
+
f"(only {', '.join(HOOK_CAPABLE)} do); falling back to capture: pane"
|
|
207
|
+
)
|
|
208
|
+
agent.capture = "pane"
|
|
209
|
+
return env
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def install_turn_detection(agent: Agent) -> dict[str, str]:
|
|
213
|
+
"""Install the correct turn-completion wiring for agent.type / capture.
|
|
214
|
+
|
|
215
|
+
Returns the extra environment dict (e.g. capture config) the launcher
|
|
216
|
+
should export into the agent's tmux session. The individual ``install_*``
|
|
217
|
+
functions stay public so other modules and tests may call them directly.
|
|
218
|
+
"""
|
|
219
|
+
if agent.type == "claude":
|
|
220
|
+
pretrust_claude_dir(agent)
|
|
221
|
+
install_claude_hook(agent)
|
|
222
|
+
return {}
|
|
223
|
+
if agent.type == "codex":
|
|
224
|
+
install_codex_hook(agent)
|
|
225
|
+
return {}
|
|
226
|
+
# gemini / hermes (pane polling): install_capture writes a capture config
|
|
227
|
+
# (or warns + downgrades an unsupported hook request) and returns the env.
|
|
228
|
+
info(f"installed turn-completion wiring for {agent.name} ({agent.type})")
|
|
229
|
+
return install_capture(agent)
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
# --------------------------------------------------------------------------
|
|
233
|
+
# lifecycle
|
|
234
|
+
# --------------------------------------------------------------------------
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def write_shim(cfg: SwarmConfig) -> None:
|
|
238
|
+
"""An `agentainer` executable the agents themselves can call."""
|
|
239
|
+
cfg.bin_dir.mkdir(parents=True, exist_ok=True)
|
|
240
|
+
shim = cfg.bin_dir / "agentainer"
|
|
241
|
+
shim.write_text(
|
|
242
|
+
"#!/usr/bin/env bash\n"
|
|
243
|
+
"# Generated by Agentainer. Lets an agent run `agentainer send ...` from its shell.\n"
|
|
244
|
+
f'exec {shlex.quote(str(AGENTAINER_HOME / "agentainer"))} "$@"\n'
|
|
245
|
+
)
|
|
246
|
+
shim.chmod(0o755)
|
package/lib/lock.py
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Agentainer -- advisory locks for serialising state mutations and pane writes.
|
|
3
|
+
|
|
4
|
+
Ported verbatim from v1's swarm.py locking helpers. The messaging layer is
|
|
5
|
+
file-based (see ``mail.py``); these locks keep that filesystem state from being
|
|
6
|
+
torn by concurrent writers (the orchestrator, the supervisor, a web UI) and
|
|
7
|
+
keep two pastes from interleaving inside one tmux pane.
|
|
8
|
+
|
|
9
|
+
Zero runtime dependencies: fcntl + stdlib only.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import contextlib
|
|
15
|
+
import sys
|
|
16
|
+
import time
|
|
17
|
+
|
|
18
|
+
try: # POSIX only, which is fine: tmux is too.
|
|
19
|
+
import fcntl
|
|
20
|
+
except ImportError: # pragma: no cover
|
|
21
|
+
fcntl = None # type: ignore
|
|
22
|
+
|
|
23
|
+
from config import SwarmConfig # noqa: E402
|
|
24
|
+
|
|
25
|
+
LOCK_TIMEOUT_S = 180
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@contextlib.contextmanager
|
|
29
|
+
def file_lock(cfg: SwarmConfig, name: str, what: str = "lock"):
|
|
30
|
+
"""An advisory cross-process lock, used to serialise access to one pane/queue.
|
|
31
|
+
|
|
32
|
+
Lock ordering, to keep it deadlock-free: queue -> pane -> turn state.
|
|
33
|
+
"""
|
|
34
|
+
if fcntl is None: # pragma: no cover
|
|
35
|
+
yield
|
|
36
|
+
return
|
|
37
|
+
|
|
38
|
+
cfg.runtime.mkdir(parents=True, exist_ok=True)
|
|
39
|
+
handle = open(cfg.runtime / f"{name}.{what}", "w")
|
|
40
|
+
deadline = time.monotonic() + LOCK_TIMEOUT_S
|
|
41
|
+
locked = False
|
|
42
|
+
try:
|
|
43
|
+
while True:
|
|
44
|
+
try:
|
|
45
|
+
fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
46
|
+
locked = True
|
|
47
|
+
break
|
|
48
|
+
except OSError:
|
|
49
|
+
if time.monotonic() > deadline:
|
|
50
|
+
print(f"{name}: timed out waiting for the {what}; proceeding anyway", file=sys.stderr)
|
|
51
|
+
break
|
|
52
|
+
time.sleep(0.05)
|
|
53
|
+
yield
|
|
54
|
+
finally:
|
|
55
|
+
if locked:
|
|
56
|
+
with contextlib.suppress(OSError):
|
|
57
|
+
fcntl.flock(handle, fcntl.LOCK_UN)
|
|
58
|
+
handle.close()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def pane_lock(cfg: SwarmConfig, session: str):
|
|
62
|
+
"""Serialise everything that types into one pane.
|
|
63
|
+
|
|
64
|
+
A paste and the Enter that submits it are two separate tmux calls. Without a
|
|
65
|
+
lock, a second sender -- another agent, or one of several subagents running
|
|
66
|
+
in parallel inside the same agent -- can paste in between them, so one Enter
|
|
67
|
+
submits two concatenated messages and the other submits nothing.
|
|
68
|
+
|
|
69
|
+
The busy check and the "mark this agent busy" write also happen under this
|
|
70
|
+
lock, so two concurrent senders cannot both observe an idle agent and both
|
|
71
|
+
deliver to it.
|
|
72
|
+
|
|
73
|
+
The lock is per recipient, so unrelated agents are still messaged in parallel.
|
|
74
|
+
"""
|
|
75
|
+
return file_lock(cfg, session, "pane.lock")
|
package/lib/log.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Durable event logging for the Agentainer orchestrator.
|
|
2
|
+
|
|
3
|
+
This module is a faithful port of v1's ``log_event`` / ``archive_message``
|
|
4
|
+
helpers from ``lib/swarm.py``. The only change from v1 is branding: the global
|
|
5
|
+
event-log file is ``agentainer.jsonl`` (v1 wrote ``swarm.jsonl``).
|
|
6
|
+
|
|
7
|
+
The global ``.agentainer/logs/agentainer.jsonl`` is the source of truth for
|
|
8
|
+
history -- fullscreen TUIs keep no scrollback, so history cannot be recovered
|
|
9
|
+
from a pane. Each agent also gets a per-agent ``<agent>.jsonl`` for quick
|
|
10
|
+
filtering.
|
|
11
|
+
|
|
12
|
+
Branding note: "swarm" is retired -- it's Agentainer everywhere (decision D21).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import shutil
|
|
19
|
+
from datetime import datetime, timezone
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from config import SwarmConfig
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def now_iso() -> str:
|
|
26
|
+
"""Current UTC time as an ISO-8601 string with second precision."""
|
|
27
|
+
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def log_event(cfg: SwarmConfig, agent: str, kind: str, **fields) -> None:
|
|
31
|
+
"""Append one JSON event record to the per-agent and global logs.
|
|
32
|
+
|
|
33
|
+
Each line is ``{"ts": ..., "agent": ..., "kind": ..., **fields}``. The same
|
|
34
|
+
record is written to ``cfg.log_dir/<agent>.jsonl`` for per-agent filtering
|
|
35
|
+
and to ``cfg.log_dir/agentainer.jsonl`` (the global source of truth).
|
|
36
|
+
"""
|
|
37
|
+
cfg.log_dir.mkdir(parents=True, exist_ok=True)
|
|
38
|
+
record = {"ts": now_iso(), "agent": agent, "kind": kind, **fields}
|
|
39
|
+
with (cfg.log_dir / f"{agent}.jsonl").open("a") as fh:
|
|
40
|
+
fh.write(json.dumps(record) + "\n")
|
|
41
|
+
with (cfg.log_dir / "agentainer.jsonl").open("a") as fh:
|
|
42
|
+
fh.write(json.dumps(record) + "\n")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def archive_message(
|
|
46
|
+
cfg: SwarmConfig,
|
|
47
|
+
agent: str,
|
|
48
|
+
src: Path,
|
|
49
|
+
*,
|
|
50
|
+
subdir: str = "archive",
|
|
51
|
+
) -> Path:
|
|
52
|
+
"""Move a handled message file into the orchestrator archive and return its path.
|
|
53
|
+
|
|
54
|
+
Used by the mailroom's best-effort auto-archive fallback (plan §7) so a
|
|
55
|
+
forgetful model can never wedge the swarm: a message presented N times
|
|
56
|
+
without being handled is moved here and the queue advances. The destination
|
|
57
|
+
is ``cfg.runtime/<subdir>/<agent>/<same-name>``.
|
|
58
|
+
"""
|
|
59
|
+
src = Path(src)
|
|
60
|
+
dest_dir = cfg.runtime / subdir / agent
|
|
61
|
+
dest_dir.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
dest = dest_dir / src.name
|
|
63
|
+
shutil.move(str(src), str(dest))
|
|
64
|
+
return dest
|