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.
Files changed (63) hide show
  1. package/examples/academic-coauthor.yaml +123 -0
  2. package/examples/accessibility-audit.yaml +152 -0
  3. package/examples/affiliate-product-reviews.yaml +106 -0
  4. package/examples/api-design.yaml +157 -0
  5. package/examples/app-store-optimization.yaml +108 -0
  6. package/examples/brand-voice-style-guide.yaml +109 -0
  7. package/examples/candidate-screen.yaml +122 -0
  8. package/examples/case-study-writer.yaml +100 -0
  9. package/examples/changelog-release-notes.yaml +114 -0
  10. package/examples/chatbot-builder.yaml +138 -0
  11. package/examples/comparison-guide-writer.yaml +106 -0
  12. package/examples/competitive-intel.yaml +126 -0
  13. package/examples/content-studio.yaml +91 -0
  14. package/examples/course-creator.yaml +133 -0
  15. package/examples/customer-support-triage.yaml +118 -0
  16. package/examples/daily-briefing.yaml +119 -0
  17. package/examples/data-pipeline-builder.yaml +135 -0
  18. package/examples/design-system.yaml +138 -0
  19. package/examples/ebook-generator.yaml +90 -0
  20. package/examples/ecommerce-listing-optimizer.yaml +126 -0
  21. package/examples/email-newsletter.yaml +103 -0
  22. package/examples/faq-knowledge-sync.yaml +107 -0
  23. package/examples/game-design.yaml +122 -0
  24. package/examples/glossary-term-writer.yaml +103 -0
  25. package/examples/knowledge-base.yaml +115 -0
  26. package/examples/landing-page-converter.yaml +103 -0
  27. package/examples/legal-contract-review.yaml +118 -0
  28. package/examples/linkedin-ghostwriter.yaml +93 -0
  29. package/examples/meeting-notes.yaml +111 -0
  30. package/examples/migration-planner.yaml +127 -0
  31. package/examples/onboarding-buddy.yaml +111 -0
  32. package/examples/performance-audit.yaml +123 -0
  33. package/examples/podcast-production.yaml +117 -0
  34. package/examples/postmortem.yaml +119 -0
  35. package/examples/pr-review-gate.yaml +123 -0
  36. package/examples/press-release-wire.yaml +96 -0
  37. package/examples/product-spec.yaml +107 -0
  38. package/examples/prompt-engineering-lab.yaml +109 -0
  39. package/examples/rag-builder.yaml +145 -0
  40. package/examples/refactor-planner.yaml +127 -0
  41. package/examples/resume-tailor.yaml +116 -0
  42. package/examples/rfp-response.yaml +124 -0
  43. package/examples/sales-coach.yaml +123 -0
  44. package/examples/security-audit.yaml +120 -0
  45. package/examples/seo-audit-and-fix.yaml +138 -0
  46. package/examples/seo-content-factory.yaml +103 -0
  47. package/examples/social-media.yaml +103 -0
  48. package/examples/startup-validator.yaml +115 -0
  49. package/examples/technical-documentation.yaml +112 -0
  50. package/examples/test-factory.yaml +114 -0
  51. package/examples/tutorial-howto-creator.yaml +111 -0
  52. package/examples/twitter-x-thread-factory.yaml +91 -0
  53. package/examples/white-paper-research.yaml +96 -0
  54. package/examples/youtube-script-studio.yaml +107 -0
  55. package/lib/cli.py +6 -2
  56. package/lib/config.py +28 -11
  57. package/lib/mail.py +78 -13
  58. package/lib/reconcile.py +80 -9
  59. package/lib/turn.py +14 -6
  60. package/lib/ui.py +212 -13
  61. package/package.json +1 -1
  62. package/ui/app.js +290 -23
  63. 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 _agent_status(self, a) -> dict:
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": tmux.session_exists(a.session),
318
- "busy": turn.busy_info(cfg, a) is not None,
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
- agents = [self._agent_status(a) for a in cfg.agents]
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
- if queue_dir.exists():
410
- for f in sorted(queue_dir.iterdir()):
411
- if f.is_file():
412
- text = f.read_text()
413
- first = text.splitlines()[0] if text.strip() else ""
414
- msgs.append({"file": f.name, "text": first})
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
- # Removing an agent that a peer still lists in can_talk_to leaves the
864
- # reloaded config invalid (dangling reference) -- surface that as 400.
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.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",