agentainer 0.1.7 → 2.0.1

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 (98) 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/academic-coauthor.yaml +123 -0
  6. package/examples/accessibility-audit.yaml +152 -0
  7. package/examples/affiliate-product-reviews.yaml +106 -0
  8. package/examples/api-design.yaml +157 -0
  9. package/examples/app-store-optimization.yaml +108 -0
  10. package/examples/brainstorm.yaml +27 -128
  11. package/examples/brand-voice-style-guide.yaml +109 -0
  12. package/examples/bug-hunt.yaml +51 -96
  13. package/examples/candidate-screen.yaml +122 -0
  14. package/examples/case-study-writer.yaml +100 -0
  15. package/examples/changelog-release-notes.yaml +114 -0
  16. package/examples/chatbot-builder.yaml +138 -0
  17. package/examples/code-review.yaml +73 -0
  18. package/examples/comparison-guide-writer.yaml +106 -0
  19. package/examples/competitive-intel.yaml +126 -0
  20. package/examples/content-studio.yaml +91 -0
  21. package/examples/course-creator.yaml +133 -0
  22. package/examples/customer-support-triage.yaml +118 -0
  23. package/examples/daily-briefing.yaml +119 -0
  24. package/examples/data-pipeline-builder.yaml +135 -0
  25. package/examples/debate.yaml +16 -90
  26. package/examples/design-system.yaml +138 -0
  27. package/examples/ebook-generator.yaml +90 -0
  28. package/examples/ecommerce-listing-optimizer.yaml +126 -0
  29. package/examples/email-newsletter.yaml +103 -0
  30. package/examples/faq-knowledge-sync.yaml +107 -0
  31. package/examples/game-design.yaml +122 -0
  32. package/examples/glossary-term-writer.yaml +103 -0
  33. package/examples/incident-response.yaml +52 -109
  34. package/examples/knowledge-base.yaml +115 -0
  35. package/examples/landing-page-converter.yaml +103 -0
  36. package/examples/legal-contract-review.yaml +118 -0
  37. package/examples/linkedin-ghostwriter.yaml +93 -0
  38. package/examples/localization.yaml +56 -123
  39. package/examples/meeting-notes.yaml +111 -0
  40. package/examples/migration-planner.yaml +127 -0
  41. package/examples/onboarding-buddy.yaml +111 -0
  42. package/examples/performance-audit.yaml +123 -0
  43. package/examples/podcast-production.yaml +117 -0
  44. package/examples/postmortem.yaml +119 -0
  45. package/examples/pr-review-gate.yaml +123 -0
  46. package/examples/press-release-wire.yaml +96 -0
  47. package/examples/product-spec.yaml +107 -0
  48. package/examples/prompt-engineering-lab.yaml +109 -0
  49. package/examples/quickstart.yaml +48 -0
  50. package/examples/rag-builder.yaml +145 -0
  51. package/examples/refactor-planner.yaml +127 -0
  52. package/examples/research.yaml +25 -0
  53. package/examples/resume-tailor.yaml +116 -0
  54. package/examples/rfp-response.yaml +124 -0
  55. package/examples/sales-coach.yaml +123 -0
  56. package/examples/security-audit.yaml +120 -0
  57. package/examples/seo-audit-and-fix.yaml +138 -0
  58. package/examples/seo-content-factory.yaml +103 -0
  59. package/examples/social-media.yaml +103 -0
  60. package/examples/software-company.yaml +71 -128
  61. package/examples/startup-validator.yaml +115 -0
  62. package/examples/tdd-pingpong.yaml +36 -68
  63. package/examples/technical-documentation.yaml +112 -0
  64. package/examples/test-factory.yaml +114 -0
  65. package/examples/tutorial-howto-creator.yaml +111 -0
  66. package/examples/twitter-x-thread-factory.yaml +91 -0
  67. package/examples/white-paper-research.yaml +96 -0
  68. package/examples/writers-room.yaml +49 -111
  69. package/examples/youtube-script-studio.yaml +107 -0
  70. package/hooks/claude_stop.sh +5 -3
  71. package/hooks/codex_notify.sh +4 -3
  72. package/lib/cli.py +933 -0
  73. package/lib/config.py +267 -308
  74. package/lib/hooks.py +246 -0
  75. package/lib/lock.py +75 -0
  76. package/lib/log.py +64 -0
  77. package/lib/mail.py +699 -0
  78. package/lib/minyaml.py +1 -39
  79. package/lib/reconcile.py +544 -0
  80. package/lib/sessions.py +223 -0
  81. package/lib/supervisor.py +216 -0
  82. package/lib/telegram.py +372 -0
  83. package/lib/tmux.py +355 -0
  84. package/lib/turn.py +167 -0
  85. package/lib/ui.py +1219 -0
  86. package/llms.txt +145 -429
  87. package/package.json +9 -7
  88. package/scripts/check-deps.js +18 -61
  89. package/ui/app.js +1136 -0
  90. package/ui/index.html +404 -0
  91. package/agents.example.yaml +0 -257
  92. package/examples/code-review-broadcast.yaml +0 -109
  93. package/examples/existing-repo.yaml +0 -74
  94. package/examples/multi-language-broadcast.yaml +0 -127
  95. package/examples/ping-pong.yaml +0 -89
  96. package/examples/red-team.yaml +0 -117
  97. package/examples/research-swarm.yaml +0 -129
  98. 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