agentainer 2.0.0 → 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.
- package/examples/academic-coauthor.yaml +123 -0
- package/examples/accessibility-audit.yaml +152 -0
- package/examples/affiliate-product-reviews.yaml +106 -0
- package/examples/api-design.yaml +157 -0
- package/examples/app-store-optimization.yaml +108 -0
- package/examples/brand-voice-style-guide.yaml +109 -0
- package/examples/candidate-screen.yaml +122 -0
- package/examples/case-study-writer.yaml +100 -0
- package/examples/changelog-release-notes.yaml +114 -0
- package/examples/chatbot-builder.yaml +138 -0
- package/examples/comparison-guide-writer.yaml +106 -0
- package/examples/competitive-intel.yaml +126 -0
- package/examples/content-studio.yaml +91 -0
- package/examples/course-creator.yaml +133 -0
- package/examples/customer-support-triage.yaml +118 -0
- package/examples/daily-briefing.yaml +119 -0
- package/examples/data-pipeline-builder.yaml +135 -0
- package/examples/design-system.yaml +138 -0
- package/examples/ebook-generator.yaml +90 -0
- package/examples/ecommerce-listing-optimizer.yaml +126 -0
- package/examples/email-newsletter.yaml +103 -0
- package/examples/faq-knowledge-sync.yaml +107 -0
- package/examples/game-design.yaml +122 -0
- package/examples/glossary-term-writer.yaml +103 -0
- package/examples/knowledge-base.yaml +115 -0
- package/examples/landing-page-converter.yaml +103 -0
- package/examples/legal-contract-review.yaml +118 -0
- package/examples/linkedin-ghostwriter.yaml +93 -0
- package/examples/meeting-notes.yaml +111 -0
- package/examples/migration-planner.yaml +127 -0
- package/examples/onboarding-buddy.yaml +111 -0
- package/examples/performance-audit.yaml +123 -0
- package/examples/podcast-production.yaml +117 -0
- package/examples/postmortem.yaml +119 -0
- package/examples/pr-review-gate.yaml +123 -0
- package/examples/press-release-wire.yaml +96 -0
- package/examples/product-spec.yaml +107 -0
- package/examples/prompt-engineering-lab.yaml +109 -0
- package/examples/rag-builder.yaml +145 -0
- package/examples/refactor-planner.yaml +127 -0
- package/examples/resume-tailor.yaml +116 -0
- package/examples/rfp-response.yaml +124 -0
- package/examples/sales-coach.yaml +123 -0
- package/examples/security-audit.yaml +120 -0
- package/examples/seo-audit-and-fix.yaml +138 -0
- package/examples/seo-content-factory.yaml +103 -0
- package/examples/social-media.yaml +103 -0
- package/examples/startup-validator.yaml +115 -0
- package/examples/technical-documentation.yaml +112 -0
- package/examples/test-factory.yaml +114 -0
- package/examples/tutorial-howto-creator.yaml +111 -0
- package/examples/twitter-x-thread-factory.yaml +91 -0
- package/examples/white-paper-research.yaml +96 -0
- package/examples/youtube-script-studio.yaml +107 -0
- package/lib/cli.py +6 -2
- package/lib/config.py +28 -11
- package/lib/mail.py +78 -13
- package/lib/reconcile.py +80 -9
- package/lib/turn.py +14 -6
- package/lib/ui.py +212 -13
- package/package.json +1 -1
- package/ui/app.js +290 -23
- package/ui/index.html +58 -2
package/lib/ui.py
CHANGED
|
@@ -32,16 +32,21 @@ The set of endpoints:
|
|
|
32
32
|
GET /api/pane?agent= -> terminal snapshot of an agent's tmux pane (token required)
|
|
33
33
|
GET /api/config -> raw swarm settings + agents (for the editor)
|
|
34
34
|
GET /api/availability -> the user's receive-mail availability toggle
|
|
35
|
+
GET /api/templates -> bundled example swarms (onboarding, empty swarm)
|
|
36
|
+
GET /api/rate?window= -> opt-in per-agent messages/min over last `window` min
|
|
35
37
|
POST /api/send -> body {"to","text"} -> mail.send_as_user
|
|
36
38
|
POST /api/type -> body {"agent","text"} -> tmux.paste_into (direct pane input)
|
|
37
39
|
POST /api/key -> body {"agent","key"} -> tmux.send_key (Escape, C-c, ...)
|
|
38
40
|
POST /api/up -> body {"agent"} -> reconcile.start_one (launch if down)
|
|
39
41
|
POST /api/down -> body {"agent"} -> reconcile.stop_one (kill session if up)
|
|
42
|
+
POST /api/up_all -> reconcile.start_all (launch every down agent; no body)
|
|
43
|
+
POST /api/down_all -> reconcile.stop_all (kill every running session; no body)
|
|
40
44
|
POST /api/config -> body {"swarm": {...}} -> persist swarm settings to YAML
|
|
41
45
|
POST /api/availability -> body {"available": bool} -> toggle + persist
|
|
42
46
|
POST /api/agent/add -> body {"name","type","command",...} -> add + persist
|
|
43
47
|
POST /api/agent/edit -> body {"name","fields": {...}} -> edit + persist
|
|
44
48
|
POST /api/agent/remove -> body {"name"} -> stop session + remove + persist
|
|
49
|
+
POST /api/templates/apply -> body {"name"} -> seed an empty swarm from a template
|
|
45
50
|
|
|
46
51
|
Every mutation rewrites ``agentainer.yaml`` (via lib/reconcile's stdlib emitter,
|
|
47
52
|
so the no-PyYAML path stays live) and swaps ``UIHandler.cfg`` for the reloaded
|
|
@@ -237,6 +242,10 @@ class UIHandler(BaseHTTPRequestHandler):
|
|
|
237
242
|
self._api_availability()
|
|
238
243
|
elif path == "/api/telegram":
|
|
239
244
|
self._api_telegram()
|
|
245
|
+
elif path == "/api/templates":
|
|
246
|
+
self._api_templates()
|
|
247
|
+
elif path == "/api/rate":
|
|
248
|
+
self._api_rate()
|
|
240
249
|
else:
|
|
241
250
|
self._send_json(404, {"error": "not found"})
|
|
242
251
|
|
|
@@ -257,6 +266,10 @@ class UIHandler(BaseHTTPRequestHandler):
|
|
|
257
266
|
self._api_up(raw)
|
|
258
267
|
elif path == "/api/down":
|
|
259
268
|
self._api_down(raw)
|
|
269
|
+
elif path == "/api/up_all":
|
|
270
|
+
self._api_up_all(raw)
|
|
271
|
+
elif path == "/api/down_all":
|
|
272
|
+
self._api_down_all(raw)
|
|
260
273
|
elif path == "/api/config":
|
|
261
274
|
self._api_config_post(raw)
|
|
262
275
|
elif path == "/api/availability":
|
|
@@ -269,6 +282,8 @@ class UIHandler(BaseHTTPRequestHandler):
|
|
|
269
282
|
self._api_agent_remove(raw)
|
|
270
283
|
elif path == "/api/telegram":
|
|
271
284
|
self._api_telegram_post(raw)
|
|
285
|
+
elif path == "/api/templates/apply":
|
|
286
|
+
self._api_templates_apply(raw)
|
|
272
287
|
elif path == "/api/telegram/test":
|
|
273
288
|
self._api_telegram_test(raw)
|
|
274
289
|
elif path == "/api/telegram/poll":
|
|
@@ -295,9 +310,46 @@ class UIHandler(BaseHTTPRequestHandler):
|
|
|
295
310
|
return None
|
|
296
311
|
return _supervisor.supervisor_alive(self.cfg)
|
|
297
312
|
|
|
298
|
-
def
|
|
313
|
+
def _pending_user_senders(self) -> set:
|
|
314
|
+
"""Names of agents whose mail to the ``user`` is still awaiting a reply.
|
|
315
|
+
|
|
316
|
+
User-directed mail is enqueued into ``queue_dir/user`` and sits there
|
|
317
|
+
until the operator reads it, so an agent named in that queue is one that
|
|
318
|
+
is waiting on *you* -- the signal behind the ``attention`` state.
|
|
319
|
+
"""
|
|
320
|
+
senders: set = set()
|
|
321
|
+
udir = self.cfg.queue_dir / "user"
|
|
322
|
+
if udir.exists():
|
|
323
|
+
for f in udir.iterdir():
|
|
324
|
+
if f.is_file():
|
|
325
|
+
senders.add(self._parse_msg(f.read_text())["from"])
|
|
326
|
+
return senders
|
|
327
|
+
|
|
328
|
+
def _agent_state(self, a, running: bool, busy_state, pending: set):
|
|
329
|
+
"""Collapse the raw signals into one truthful state + its working age.
|
|
330
|
+
|
|
331
|
+
Priority: stopped > working > stalled > attention > waiting. ``stalled``
|
|
332
|
+
is the anomaly ``busy_info`` hides -- a turn that has looked busy past
|
|
333
|
+
``busy_timeout_ms`` (the completion signal was lost), which would
|
|
334
|
+
otherwise silently read as idle.
|
|
335
|
+
"""
|
|
336
|
+
if not running:
|
|
337
|
+
return "stopped", 0
|
|
338
|
+
if busy_state is not None:
|
|
339
|
+
return "working", int(busy_state.get("age_s", 0))
|
|
340
|
+
if a.busy_check:
|
|
341
|
+
st = turn.turn_state(self.cfg, a.name)
|
|
342
|
+
if st.get("delivered", 0) > st.get("completed", 0):
|
|
343
|
+
return "stalled", 0
|
|
344
|
+
if a.name in pending:
|
|
345
|
+
return "attention", 0
|
|
346
|
+
return "waiting", 0
|
|
347
|
+
|
|
348
|
+
def _agent_status(self, a, pending=None) -> dict:
|
|
299
349
|
"""The live status row for one agent (shared by /api/status + /api/agent)."""
|
|
300
350
|
cfg = self.cfg
|
|
351
|
+
if pending is None:
|
|
352
|
+
pending = self._pending_user_senders()
|
|
301
353
|
mp = cfg.mail_paths(a)
|
|
302
354
|
queue_dir = cfg.queue_dir / a.name
|
|
303
355
|
queue_depth = (
|
|
@@ -311,11 +363,17 @@ class UIHandler(BaseHTTPRequestHandler):
|
|
|
311
363
|
if inbox_dir.exists()
|
|
312
364
|
else 0
|
|
313
365
|
)
|
|
366
|
+
running = tmux.session_exists(a.session)
|
|
367
|
+
busy_state = turn.busy_info(cfg, a)
|
|
368
|
+
state, working_s = self._agent_state(a, running, busy_state, pending)
|
|
314
369
|
return {
|
|
315
370
|
"name": a.name,
|
|
316
371
|
"type": a.type,
|
|
317
|
-
"running":
|
|
318
|
-
"busy":
|
|
372
|
+
"running": running,
|
|
373
|
+
"busy": busy_state is not None,
|
|
374
|
+
"state": state,
|
|
375
|
+
"working_s": working_s,
|
|
376
|
+
"awaiting_user": a.name in pending,
|
|
319
377
|
"queue_depth": queue_depth,
|
|
320
378
|
"unread": unread,
|
|
321
379
|
"can_talk_to": a.can_talk_to,
|
|
@@ -323,7 +381,12 @@ class UIHandler(BaseHTTPRequestHandler):
|
|
|
323
381
|
|
|
324
382
|
def _api_status(self) -> None:
|
|
325
383
|
cfg = self.cfg
|
|
326
|
-
|
|
384
|
+
pending = self._pending_user_senders()
|
|
385
|
+
agents = [self._agent_status(a, pending) for a in cfg.agents]
|
|
386
|
+
udir = cfg.queue_dir / "user"
|
|
387
|
+
attention = (
|
|
388
|
+
len([f for f in udir.iterdir() if f.is_file()]) if udir.exists() else 0
|
|
389
|
+
)
|
|
327
390
|
self._send_json(
|
|
328
391
|
200,
|
|
329
392
|
{
|
|
@@ -331,6 +394,7 @@ class UIHandler(BaseHTTPRequestHandler):
|
|
|
331
394
|
"root": str(cfg.root),
|
|
332
395
|
"user_available": bool(cfg.user_available),
|
|
333
396
|
"supervisor_alive": self._supervisor_alive(),
|
|
397
|
+
"attention": attention,
|
|
334
398
|
"agents": agents,
|
|
335
399
|
},
|
|
336
400
|
)
|
|
@@ -404,14 +468,13 @@ class UIHandler(BaseHTTPRequestHandler):
|
|
|
404
468
|
except Exception:
|
|
405
469
|
self._send_json(404, {"error": "unknown agent"})
|
|
406
470
|
return
|
|
407
|
-
queue_dir = self.cfg.queue_dir / agent
|
|
408
471
|
msgs = []
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
472
|
+
# FIFO (enqueue) order -- the order these will actually be delivered --
|
|
473
|
+
# not random message-id filename order (see mail.queued_files).
|
|
474
|
+
for f in mail.queued_files(self.cfg, agent):
|
|
475
|
+
text = f.read_text()
|
|
476
|
+
first = text.splitlines()[0] if text.strip() else ""
|
|
477
|
+
msgs.append({"file": f.name, "text": first})
|
|
415
478
|
self._send_json(200, {"agent": agent, "queue": msgs})
|
|
416
479
|
|
|
417
480
|
# -- API: pane (terminal snapshot) ---------------------------------------
|
|
@@ -749,6 +812,141 @@ class UIHandler(BaseHTTPRequestHandler):
|
|
|
749
812
|
return
|
|
750
813
|
self._send_json(200, {"ok": True, "agent": name, "stopped": bool(stopped)})
|
|
751
814
|
|
|
815
|
+
def _api_up_all(self, raw: bytes) -> None:
|
|
816
|
+
"""Start every configured-but-not-running agent (body ignored)."""
|
|
817
|
+
try:
|
|
818
|
+
started = reconcile.start_all(self.cfg)
|
|
819
|
+
except Exception as exc:
|
|
820
|
+
self._send_json(400, {"error": str(exc)})
|
|
821
|
+
return
|
|
822
|
+
self._send_json(200, {"ok": True, "started": started})
|
|
823
|
+
|
|
824
|
+
def _api_down_all(self, raw: bytes) -> None:
|
|
825
|
+
"""Kill every running agent session (body ignored; config untouched)."""
|
|
826
|
+
try:
|
|
827
|
+
stopped = reconcile.stop_all(self.cfg)
|
|
828
|
+
except Exception as exc:
|
|
829
|
+
self._send_json(400, {"error": str(exc)})
|
|
830
|
+
return
|
|
831
|
+
self._send_json(200, {"ok": True, "stopped": stopped})
|
|
832
|
+
|
|
833
|
+
# -- API: templates (bundled example swarms, for onboarding) ---------------
|
|
834
|
+
|
|
835
|
+
def _templates_dir(self) -> Path:
|
|
836
|
+
"""The bundled ``examples/`` directory shipped alongside ``lib/``."""
|
|
837
|
+
return Path(__file__).resolve().parent.parent / "examples"
|
|
838
|
+
|
|
839
|
+
def _template_summary(self, path) -> str:
|
|
840
|
+
"""First meaningful ``# comment`` line of an example (its description).
|
|
841
|
+
|
|
842
|
+
Skips decorative banners (``# ====``) and blank ``#`` lines so the card
|
|
843
|
+
shows the real one-line blurb, not a row of separators.
|
|
844
|
+
"""
|
|
845
|
+
try:
|
|
846
|
+
for line in Path(path).read_text().splitlines():
|
|
847
|
+
s = line.strip()
|
|
848
|
+
if s.startswith("#"):
|
|
849
|
+
s = s.lstrip("#").strip()
|
|
850
|
+
if any(c.isalnum() for c in s): # skip banners / blank comments
|
|
851
|
+
return s[:160]
|
|
852
|
+
continue
|
|
853
|
+
if s: # first content line before any real comment -> no summary
|
|
854
|
+
break
|
|
855
|
+
except OSError: # pragma: no cover - unreadable file degrades to ""
|
|
856
|
+
pass
|
|
857
|
+
return ""
|
|
858
|
+
|
|
859
|
+
def _api_templates(self) -> None:
|
|
860
|
+
tdir = self._templates_dir()
|
|
861
|
+
out = []
|
|
862
|
+
if tdir.exists():
|
|
863
|
+
for p in sorted(tdir.glob("*.yaml")):
|
|
864
|
+
try:
|
|
865
|
+
raw = reconcile.load_raw(p)
|
|
866
|
+
except Exception:
|
|
867
|
+
continue
|
|
868
|
+
swarm = raw.get("swarm") or {}
|
|
869
|
+
title = swarm.get("name") or p.stem.replace("-", " ").replace("_", " ").title()
|
|
870
|
+
out.append({
|
|
871
|
+
"name": p.stem,
|
|
872
|
+
"title": title,
|
|
873
|
+
"summary": self._template_summary(p),
|
|
874
|
+
"agents": len(raw.get("agents") or []),
|
|
875
|
+
})
|
|
876
|
+
self._send_json(200, {"templates": out})
|
|
877
|
+
|
|
878
|
+
def _api_templates_apply(self, raw: bytes) -> None:
|
|
879
|
+
data = self._json_body(raw)
|
|
880
|
+
if data is None:
|
|
881
|
+
return
|
|
882
|
+
name = data.get("name")
|
|
883
|
+
if not name:
|
|
884
|
+
self._send_json(400, {"error": "missing name"})
|
|
885
|
+
return
|
|
886
|
+
# Templates seed a fresh swarm; refuse if agents already exist.
|
|
887
|
+
if self.cfg.names():
|
|
888
|
+
self._send_json(400, {"error": "swarm already has agents"})
|
|
889
|
+
return
|
|
890
|
+
tdir = self._templates_dir()
|
|
891
|
+
# Validate against the real listing so ``name`` can't escape the dir.
|
|
892
|
+
valid = {p.stem for p in tdir.glob("*.yaml")} if tdir.exists() else set()
|
|
893
|
+
if name not in valid:
|
|
894
|
+
self._send_json(404, {"error": "unknown template"})
|
|
895
|
+
return
|
|
896
|
+
try:
|
|
897
|
+
raw_tpl = reconcile.load_raw(tdir / f"{name}.yaml")
|
|
898
|
+
added = reconcile.apply_template(
|
|
899
|
+
self.cfg, raw_tpl.get("agents") or [], raw_tpl.get("defaults")
|
|
900
|
+
)
|
|
901
|
+
new_cfg = config.load(self.cfg.path)
|
|
902
|
+
except Exception as exc:
|
|
903
|
+
self._send_json(400, {"error": str(exc)})
|
|
904
|
+
return
|
|
905
|
+
UIHandler.cfg = new_cfg
|
|
906
|
+
mail.init_mailboxes(new_cfg)
|
|
907
|
+
self._send_json(200, {"ok": True, "applied": name, "added": added})
|
|
908
|
+
|
|
909
|
+
# -- API: rate (opt-in per-agent message throughput) ----------------------
|
|
910
|
+
|
|
911
|
+
MESSAGE_KINDS = {"delivered", "user-send"}
|
|
912
|
+
|
|
913
|
+
def _api_rate(self) -> None:
|
|
914
|
+
from datetime import datetime, timedelta, timezone
|
|
915
|
+
|
|
916
|
+
qs = parse_qs(urlparse(self.path).query)
|
|
917
|
+
try:
|
|
918
|
+
window = int(qs.get("window", ["5"])[0])
|
|
919
|
+
except ValueError:
|
|
920
|
+
window = 5
|
|
921
|
+
if window <= 0:
|
|
922
|
+
window = 5
|
|
923
|
+
cutoff = datetime.now(timezone.utc) - timedelta(minutes=window)
|
|
924
|
+
counts: dict = {}
|
|
925
|
+
total = 0
|
|
926
|
+
logfile = self.cfg.log_dir / "agentainer.jsonl"
|
|
927
|
+
if logfile.exists():
|
|
928
|
+
for line in logfile.read_text().splitlines():
|
|
929
|
+
line = line.strip()
|
|
930
|
+
if not line:
|
|
931
|
+
continue
|
|
932
|
+
try:
|
|
933
|
+
rec = json.loads(line)
|
|
934
|
+
except json.JSONDecodeError:
|
|
935
|
+
continue
|
|
936
|
+
if rec.get("kind") not in self.MESSAGE_KINDS:
|
|
937
|
+
continue
|
|
938
|
+
try:
|
|
939
|
+
ts = datetime.fromisoformat(rec["ts"])
|
|
940
|
+
except (KeyError, ValueError):
|
|
941
|
+
continue
|
|
942
|
+
if ts < cutoff:
|
|
943
|
+
continue
|
|
944
|
+
agent = rec.get("agent") or "?"
|
|
945
|
+
counts[agent] = counts.get(agent, 0) + 1
|
|
946
|
+
total += 1
|
|
947
|
+
rates = {a: round(n / window, 2) for a, n in counts.items()}
|
|
948
|
+
self._send_json(200, {"window_min": window, "rates": rates, "total": total})
|
|
949
|
+
|
|
752
950
|
# -- API: config (persist swarm settings) ---------------------------------
|
|
753
951
|
|
|
754
952
|
def _api_config_post(self, raw: bytes) -> None:
|
|
@@ -860,8 +1058,9 @@ class UIHandler(BaseHTTPRequestHandler):
|
|
|
860
1058
|
if tmux.session_exists(a.session):
|
|
861
1059
|
tmux.tmux("kill-session", "-t", f"={a.session}", check=False, capture=True)
|
|
862
1060
|
try:
|
|
863
|
-
#
|
|
864
|
-
#
|
|
1061
|
+
# remove_agent also strips the agent from every peer's can_talk_to,
|
|
1062
|
+
# so this can't leave a dangling reference; any other failure is
|
|
1063
|
+
# surfaced as 400 with the config left intact (see reconcile._commit).
|
|
865
1064
|
new_cfg = reconcile.remove_agent(self.cfg, name)
|
|
866
1065
|
except Exception as exc:
|
|
867
1066
|
self._send_json(400, {"error": str(exc)})
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agentainer",
|
|
3
|
-
"version": "2.0.
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"description": "Zero-dependency multi-agent orchestrator with a file-based mail model for AI coding agents (Claude Code, Codex, Gemini, Hermes). Agents talk by reading and writing files; the orchestrator routes the mail.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"agents",
|