agentainer 0.1.7 → 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.
Files changed (44) 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/brainstorm.yaml +27 -128
  6. package/examples/bug-hunt.yaml +51 -96
  7. package/examples/code-review.yaml +73 -0
  8. package/examples/debate.yaml +16 -90
  9. package/examples/incident-response.yaml +52 -109
  10. package/examples/localization.yaml +56 -123
  11. package/examples/quickstart.yaml +48 -0
  12. package/examples/research.yaml +25 -0
  13. package/examples/software-company.yaml +71 -128
  14. package/examples/tdd-pingpong.yaml +36 -68
  15. package/examples/writers-room.yaml +49 -111
  16. package/hooks/claude_stop.sh +5 -3
  17. package/hooks/codex_notify.sh +4 -3
  18. package/lib/cli.py +929 -0
  19. package/lib/config.py +247 -305
  20. package/lib/hooks.py +246 -0
  21. package/lib/lock.py +75 -0
  22. package/lib/log.py +64 -0
  23. package/lib/mail.py +634 -0
  24. package/lib/minyaml.py +1 -39
  25. package/lib/reconcile.py +473 -0
  26. package/lib/sessions.py +223 -0
  27. package/lib/supervisor.py +216 -0
  28. package/lib/telegram.py +372 -0
  29. package/lib/tmux.py +355 -0
  30. package/lib/turn.py +159 -0
  31. package/lib/ui.py +1020 -0
  32. package/llms.txt +145 -429
  33. package/package.json +9 -7
  34. package/scripts/check-deps.js +18 -61
  35. package/ui/app.js +869 -0
  36. package/ui/index.html +348 -0
  37. package/agents.example.yaml +0 -257
  38. package/examples/code-review-broadcast.yaml +0 -109
  39. package/examples/existing-repo.yaml +0 -74
  40. package/examples/multi-language-broadcast.yaml +0 -127
  41. package/examples/ping-pong.yaml +0 -89
  42. package/examples/red-team.yaml +0 -117
  43. package/examples/research-swarm.yaml +0 -129
  44. package/lib/swarm.py +0 -2461
package/lib/ui.py ADDED
@@ -0,0 +1,1020 @@
1
+ #!/usr/bin/env python3
2
+ """Agentainer -- the HTTP control plane / observability UI (P2).
3
+
4
+ This is the only module in the v2 rewrite that talks HTTP. It is a deliberately
5
+ THIN shell over the already-tested core modules (``config``, ``tmux``, ``turn``,
6
+ ``mail``, ``supervisor``): every handler reads orchestrator state and never
7
+ re-implements routing / ACL / busy-tracking / queueing. The model/agent only
8
+ reads and writes files; this server only *reports* that state and lets a human
9
+ inject mail as the virtual ``user`` mailbox.
10
+
11
+ Hard invariants (see CLAUDE.md + ProjectPlan.md §24):
12
+ * stdlib ``http.server`` only -- no framework, no build step, no deps.
13
+ * Binds ``127.0.0.1`` by default. A token is REQUIRED for any non-loopback
14
+ bind (see ``_is_loopback`` + the guard in ``run_server``).
15
+ * The UI is a control plane: it can start processes / type into agents, so it
16
+ MUST be bound to loopback unless a token is supplied.
17
+ * Handlers are small and unit-testable so ``lib/ui.py`` can hit 100% coverage
18
+ with mock agents (no real tmux sessions, no API keys).
19
+
20
+ The set of endpoints:
21
+
22
+ GET / -> ui/index.html (token-exempt, loads the page)
23
+ GET /app.js -> ui/app.js (token-exempt static asset)
24
+ GET /api/status -> swarm + per-agent status (token required)
25
+ GET /api/agents -> agent list (token required)
26
+ GET /api/agent?agent= -> one agent's full detail + live status
27
+ GET /api/contacts?agent= -> mail-app contact list for an agent (unread, last msg)
28
+ GET /api/thread?agent=&peer= -> the full bidirectional thread between two mailboxes
29
+ GET /api/logs?agent=&n= -> last n JSONL log records (token required)
30
+ GET /api/inbox?agent= -> current inbox message(s) (token required)
31
+ GET /api/queue?agent= -> queued message(s) (token required)
32
+ GET /api/pane?agent= -> terminal snapshot of an agent's tmux pane (token required)
33
+ GET /api/config -> raw swarm settings + agents (for the editor)
34
+ GET /api/availability -> the user's receive-mail availability toggle
35
+ POST /api/send -> body {"to","text"} -> mail.send_as_user
36
+ POST /api/type -> body {"agent","text"} -> tmux.paste_into (direct pane input)
37
+ POST /api/key -> body {"agent","key"} -> tmux.send_key (Escape, C-c, ...)
38
+ POST /api/up -> body {"agent"} -> reconcile.start_one (launch if down)
39
+ POST /api/down -> body {"agent"} -> reconcile.stop_one (kill session if up)
40
+ POST /api/config -> body {"swarm": {...}} -> persist swarm settings to YAML
41
+ POST /api/availability -> body {"available": bool} -> toggle + persist
42
+ POST /api/agent/add -> body {"name","type","command",...} -> add + persist
43
+ POST /api/agent/edit -> body {"name","fields": {...}} -> edit + persist
44
+ POST /api/agent/remove -> body {"name"} -> stop session + remove + persist
45
+
46
+ Every mutation rewrites ``agentainer.yaml`` (via lib/reconcile's stdlib emitter,
47
+ so the no-PyYAML path stays live) and swaps ``UIHandler.cfg`` for the reloaded
48
+ config so subsequent requests see the change.
49
+
50
+ Static assets are token-EXEMPT so the login page can load; every API call and
51
+ every POST requires the token (query param ``?token=`` or
52
+ ``Authorization: Bearer <token>`` header).
53
+
54
+ Branding: "swarm" is retired -- it's Agentainer everywhere (decision D21).
55
+ """
56
+
57
+ from __future__ import annotations
58
+
59
+ import json
60
+ import sys
61
+ import threading
62
+ from pathlib import Path
63
+ from urllib.parse import parse_qs, urlparse
64
+
65
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
66
+
67
+ # The lib modules import one another by bare name (config, tmux, ...); the test
68
+ # harness and CLI put ``lib/`` on ``sys.path``. Make that true for this module
69
+ # too, so it is importable standalone (e.g. ``python3 -m lib.ui`` is not needed,
70
+ # but ``import ui`` must work from anywhere lib/ is on the path).
71
+ _LIB = Path(__file__).resolve().parent
72
+ if str(_LIB) not in sys.path:
73
+ sys.path.insert(0, str(_LIB))
74
+
75
+ import config # noqa: E402
76
+ import mail # noqa: E402
77
+ import reconcile # noqa: E402
78
+ import telegram # noqa: E402
79
+ import tmux # noqa: E402
80
+ import turn # noqa: E402
81
+
82
+ # supervisor is imported lazily inside _api_status so a checkout that lacks it
83
+ # (or a test that hides it) degrades gracefully instead of crashing the server.
84
+
85
+
86
+ # The directory holding the static UI assets (index.html, app.js). Defaults to
87
+ # the repo's ``ui/`` directory; ``run_server`` accepts an override (used by tests
88
+ # to point at a temp dir and exercise the missing-asset branch).
89
+ _DEFAULT_UI_DIR = _LIB.parent / "ui"
90
+
91
+ # Hosts we treat as loopback. Binding any OTHER host requires a token, because
92
+ # the UI can start processes and type into agents.
93
+ _LOOPBACK_HOSTS = ("127.0.0.1", "localhost", "::1", "0:0:0:0:0:0:0:1")
94
+
95
+ # Module-global handle to the most recently created server. Lets the foreground
96
+ # ``serve`` CLI command (and the foreground branch test) stop a server that is
97
+ # blocking in ``serve_forever`` from another thread.
98
+ _last_server = None
99
+
100
+ # Module-global Telegram reply poller (started/stopped from the UI). Only one
101
+ # runs per serve process; None when polling is off. ``ThreadingHTTPServer`` hands
102
+ # each request to its own thread, so all start/stop/restart of the poller goes
103
+ # through ``_tg_lock`` -- otherwise two concurrent poll requests could each see
104
+ # ``None`` and start (and leak) a second poller.
105
+ _tg_poller = None
106
+ _tg_lock = threading.Lock()
107
+
108
+
109
+ def _is_loopback(host: str) -> bool:
110
+ """True iff *host* resolves to the local machine only (safe without a token)."""
111
+ return host in _LOOPBACK_HOSTS
112
+
113
+
114
+ class ServerHandle:
115
+ """A started UI server: know the real port + stop it cleanly.
116
+
117
+ ``run_server`` returns one of these. Use ``.port`` (the real bound port, even
118
+ when you passed ``port=0``) and ``.shutdown()`` to stop the background thread.
119
+ """
120
+
121
+ def __init__(self, server: ThreadingHTTPServer, port: int, thread):
122
+ self.server = server
123
+ self.port = port
124
+ self.thread = thread
125
+
126
+ @property
127
+ def url(self) -> str:
128
+ host = self.server.server_address[0]
129
+ return f"http://{host}:{self.port}"
130
+
131
+ def shutdown(self) -> None:
132
+ """Stop the HTTP server and join its background thread (best effort)."""
133
+ global _tg_poller
134
+ with _tg_lock:
135
+ if _tg_poller is not None:
136
+ _tg_poller.stop()
137
+ _tg_poller = None
138
+ self.server.shutdown()
139
+ if self.thread is not None:
140
+ self.thread.join(timeout=5)
141
+
142
+ def __enter__(self) -> "ServerHandle":
143
+ return self
144
+
145
+ def __exit__(self, *exc) -> None:
146
+ self.shutdown()
147
+
148
+
149
+ class UIHandler(BaseHTTPRequestHandler):
150
+ """Thin request handler over the core modules.
151
+
152
+ ``cfg``, ``token`` and ``ui_dir`` are set as class attributes on
153
+ ``UIHandler`` by ``run_server`` before the server starts accepting traffic.
154
+ """
155
+
156
+ cfg = None
157
+ token = None
158
+ ui_dir = None
159
+ # Keep the test output quiet; the orchestrator has its own logs.
160
+ protocol_version = "HTTP/1.0"
161
+
162
+ # -- low-level responders -------------------------------------------------
163
+
164
+ def log_message(self, format, *args): # noqa: A002 - BaseHTTPRequestHandler API
165
+ # Suppress the default stderr logging; tests assert on responses instead.
166
+ pass
167
+
168
+ def _send_json(self, code: int, obj: dict) -> None:
169
+ body = json.dumps(obj).encode()
170
+ self.send_response(code)
171
+ self.send_header("Content-Type", "application/json")
172
+ self.send_header("Content-Length", str(len(body)))
173
+ self.end_headers()
174
+ self.wfile.write(body)
175
+
176
+ def _send_text(self, code: int, text: str, content_type: str) -> None:
177
+ body = text.encode()
178
+ self.send_response(code)
179
+ self.send_header("Content-Type", content_type)
180
+ self.send_header("Content-Length", str(len(body)))
181
+ self.end_headers()
182
+ self.wfile.write(body)
183
+
184
+ # -- auth -----------------------------------------------------------------
185
+
186
+ def _token_valid(self) -> bool:
187
+ """Accept the token via ``?token=`` or ``Authorization: Bearer <token>``."""
188
+ parsed = urlparse(self.path)
189
+ qs = parse_qs(parsed.query)
190
+ if "token" in qs and qs["token"][0] == self.token:
191
+ return True
192
+ auth = self.headers.get("Authorization", "")
193
+ if auth.startswith("Bearer "):
194
+ if auth[len("Bearer "):].strip() == self.token:
195
+ return True
196
+ return False
197
+
198
+ def _auth_required(self) -> bool:
199
+ """Static assets are token-exempt; everything else needs the token."""
200
+ path = urlparse(self.path).path
201
+ if path in ("/", "/index.html", "/app.js"):
202
+ return False
203
+ return True
204
+
205
+ # -- routing --------------------------------------------------------------
206
+
207
+ def do_GET(self) -> None:
208
+ if self._auth_required() and not self._token_valid():
209
+ self._send_json(401, {"error": "unauthorized"})
210
+ return
211
+ path = urlparse(self.path).path
212
+ if path in ("/", "/index.html"):
213
+ self._serve_static("index.html", "text/html; charset=utf-8")
214
+ elif path == "/app.js":
215
+ self._serve_static("app.js", "text/javascript; charset=utf-8")
216
+ elif path == "/api/status":
217
+ self._api_status()
218
+ elif path == "/api/agents":
219
+ self._api_agents()
220
+ elif path == "/api/agent":
221
+ self._api_agent()
222
+ elif path == "/api/contacts":
223
+ self._api_contacts()
224
+ elif path == "/api/thread":
225
+ self._api_thread()
226
+ elif path == "/api/logs":
227
+ self._api_logs()
228
+ elif path == "/api/inbox":
229
+ self._api_inbox()
230
+ elif path == "/api/queue":
231
+ self._api_queue()
232
+ elif path == "/api/pane":
233
+ self._api_pane()
234
+ elif path == "/api/config":
235
+ self._api_config()
236
+ elif path == "/api/availability":
237
+ self._api_availability()
238
+ elif path == "/api/telegram":
239
+ self._api_telegram()
240
+ else:
241
+ self._send_json(404, {"error": "not found"})
242
+
243
+ def do_POST(self) -> None:
244
+ length = int(self.headers.get("Content-Length", 0) or 0)
245
+ raw = self.rfile.read(length) if length > 0 else b""
246
+ if not self._token_valid():
247
+ self._send_json(401, {"error": "unauthorized"})
248
+ return
249
+ path = urlparse(self.path).path
250
+ if path == "/api/send":
251
+ self._api_send(raw)
252
+ elif path == "/api/type":
253
+ self._api_type(raw)
254
+ elif path == "/api/key":
255
+ self._api_key(raw)
256
+ elif path == "/api/up":
257
+ self._api_up(raw)
258
+ elif path == "/api/down":
259
+ self._api_down(raw)
260
+ elif path == "/api/config":
261
+ self._api_config_post(raw)
262
+ elif path == "/api/availability":
263
+ self._api_availability_post(raw)
264
+ elif path == "/api/agent/add":
265
+ self._api_agent_add(raw)
266
+ elif path == "/api/agent/edit":
267
+ self._api_agent_edit(raw)
268
+ elif path == "/api/agent/remove":
269
+ self._api_agent_remove(raw)
270
+ elif path == "/api/telegram":
271
+ self._api_telegram_post(raw)
272
+ elif path == "/api/telegram/test":
273
+ self._api_telegram_test(raw)
274
+ elif path == "/api/telegram/poll":
275
+ self._api_telegram_poll(raw)
276
+ else:
277
+ self._send_json(404, {"error": "not found"})
278
+
279
+ # -- static ---------------------------------------------------------------
280
+
281
+ def _serve_static(self, name: str, content_type: str) -> None:
282
+ p = Path(self.ui_dir) / name
283
+ if not p.exists():
284
+ self._send_text(404, "not found", "text/plain")
285
+ return
286
+ self._send_text(200, p.read_text(), content_type)
287
+
288
+ # -- API: status ----------------------------------------------------------
289
+
290
+ def _supervisor_alive(self):
291
+ """Lazily import supervisor so an absent module degrades to None."""
292
+ try:
293
+ import supervisor as _supervisor # noqa: F401
294
+ except Exception:
295
+ return None
296
+ return _supervisor.supervisor_alive(self.cfg)
297
+
298
+ def _agent_status(self, a) -> dict:
299
+ """The live status row for one agent (shared by /api/status + /api/agent)."""
300
+ cfg = self.cfg
301
+ mp = cfg.mail_paths(a)
302
+ queue_dir = cfg.queue_dir / a.name
303
+ queue_depth = (
304
+ len([f for f in queue_dir.iterdir() if f.is_file()])
305
+ if queue_dir.exists()
306
+ else 0
307
+ )
308
+ inbox_dir = mp.inbox
309
+ unread = (
310
+ len([f for f in inbox_dir.iterdir() if f.is_file()])
311
+ if inbox_dir.exists()
312
+ else 0
313
+ )
314
+ return {
315
+ "name": a.name,
316
+ "type": a.type,
317
+ "running": tmux.session_exists(a.session),
318
+ "busy": turn.busy_info(cfg, a) is not None,
319
+ "queue_depth": queue_depth,
320
+ "unread": unread,
321
+ "can_talk_to": a.can_talk_to,
322
+ }
323
+
324
+ def _api_status(self) -> None:
325
+ cfg = self.cfg
326
+ agents = [self._agent_status(a) for a in cfg.agents]
327
+ self._send_json(
328
+ 200,
329
+ {
330
+ "name": cfg.name,
331
+ "root": str(cfg.root),
332
+ "user_available": bool(cfg.user_available),
333
+ "supervisor_alive": self._supervisor_alive(),
334
+ "agents": agents,
335
+ },
336
+ )
337
+
338
+ # -- API: agents ----------------------------------------------------------
339
+
340
+ def _api_agents(self) -> None:
341
+ agents = [
342
+ {"name": a.name, "type": a.type, "can_talk_to": a.can_talk_to}
343
+ for a in self.cfg.agents
344
+ ]
345
+ self._send_json(200, {"agents": agents})
346
+
347
+ # -- API: logs ------------------------------------------------------------
348
+
349
+ def _api_logs(self) -> None:
350
+ qs = parse_qs(urlparse(self.path).query)
351
+ agent = qs.get("agent", [None])[0]
352
+ try:
353
+ n = int(qs.get("n", ["50"])[0])
354
+ except ValueError:
355
+ n = 50
356
+ if agent:
357
+ logfile = self.cfg.log_dir / f"{agent}.jsonl"
358
+ else:
359
+ logfile = self.cfg.log_dir / "agentainer.jsonl"
360
+ records = []
361
+ if logfile.exists():
362
+ lines = logfile.read_text().splitlines()
363
+ for line in lines[-n:]:
364
+ line = line.strip()
365
+ if not line:
366
+ continue
367
+ try:
368
+ records.append(json.loads(line))
369
+ except json.JSONDecodeError:
370
+ records.append({"raw": line})
371
+ self._send_json(200, {"agent": agent, "logs": records})
372
+
373
+ # -- API: inbox -----------------------------------------------------------
374
+
375
+ def _api_inbox(self) -> None:
376
+ qs = parse_qs(urlparse(self.path).query)
377
+ agent = qs.get("agent", [None])[0]
378
+ if not agent:
379
+ self._send_json(400, {"error": "missing agent"})
380
+ return
381
+ try:
382
+ a = self.cfg.get(agent)
383
+ except Exception:
384
+ self._send_json(404, {"error": "unknown agent"})
385
+ return
386
+ mp = self.cfg.mail_paths(a)
387
+ msgs = []
388
+ if mp.inbox.exists():
389
+ for f in sorted(mp.inbox.iterdir()):
390
+ if f.is_file():
391
+ msgs.append({"file": f.name, "text": f.read_text()})
392
+ self._send_json(200, {"agent": agent, "inbox": msgs})
393
+
394
+ # -- API: queue -----------------------------------------------------------
395
+
396
+ def _api_queue(self) -> None:
397
+ qs = parse_qs(urlparse(self.path).query)
398
+ agent = qs.get("agent", [None])[0]
399
+ if not agent:
400
+ self._send_json(400, {"error": "missing agent"})
401
+ return
402
+ try:
403
+ self.cfg.get(agent)
404
+ except Exception:
405
+ self._send_json(404, {"error": "unknown agent"})
406
+ return
407
+ queue_dir = self.cfg.queue_dir / agent
408
+ 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})
415
+ self._send_json(200, {"agent": agent, "queue": msgs})
416
+
417
+ # -- API: pane (terminal snapshot) ---------------------------------------
418
+
419
+ def _api_pane(self) -> None:
420
+ qs = parse_qs(urlparse(self.path).query)
421
+ agent = qs.get("agent", [None])[0]
422
+ if not agent:
423
+ self._send_json(400, {"error": "missing agent"})
424
+ return
425
+ try:
426
+ a = self.cfg.get(agent)
427
+ except Exception:
428
+ self._send_json(404, {"error": "unknown agent"})
429
+ return
430
+ # capture_pane returns "" when the session is down / tmux errors, so the
431
+ # UI just renders an empty pane rather than failing the request.
432
+ self._send_json(200, {"agent": agent, "pane": tmux.capture_pane(self.cfg, a)})
433
+
434
+ # -- API: agent detail ----------------------------------------------------
435
+
436
+ def _api_agent(self) -> None:
437
+ qs = parse_qs(urlparse(self.path).query)
438
+ name = qs.get("agent", [None])[0]
439
+ if not name:
440
+ self._send_json(400, {"error": "missing agent"})
441
+ return
442
+ try:
443
+ a = self.cfg.get(name)
444
+ except Exception:
445
+ self._send_json(404, {"error": "unknown agent"})
446
+ return
447
+ d = self._agent_status(a)
448
+ d.update(
449
+ {
450
+ "command": a.command,
451
+ "role": a.role,
452
+ "workdir": str(a.workdir),
453
+ "capture": a.capture,
454
+ "session": a.session,
455
+ "periodically_ping_seconds": a.periodically_ping_seconds,
456
+ "periodically_ping_message": a.periodically_ping_message,
457
+ }
458
+ )
459
+ self._send_json(200, {"agent": d})
460
+
461
+ # -- mail-app helpers (thread reconstruction) -----------------------------
462
+
463
+ def _parse_msg(self, text: str) -> dict:
464
+ """Parse a stamped mail file into ``{from,to,id,time,body}``.
465
+
466
+ The model never writes headers -- the orchestrator stamps every routed
467
+ message (``mail.stamp_message``), so every *received* copy carries them.
468
+ """
469
+ parts = text.split("\n\n", 1)
470
+ body = parts[1] if len(parts) > 1 else text
471
+ return {
472
+ "from": mail._parse_header_field(text, "From"),
473
+ "to": mail._parse_header_field(text, "To"),
474
+ "id": mail._parse_header_field(text, "Id"),
475
+ "time": mail._parse_header_field(text, "Time"),
476
+ "body": body,
477
+ }
478
+
479
+ def _incoming_dirs(self, name: str) -> list:
480
+ """``(dir, status)`` pairs holding STAMPED messages *received* by ``name``.
481
+
482
+ Every routed message lands, stamped, in its recipient's queue/inbox/read
483
+ (and, if force-archived, the archive), and the folder it currently sits in
484
+ IS its delivery status: ``queued`` (waiting to be released) -> ``delivered``
485
+ (presented, unread) -> ``read`` (done). ``user`` is virtual -- messages to
486
+ it accumulate in its queue, which is effectively its inbox, so we label
487
+ those ``delivered``. ``system`` never receives. Scanning the recipient side
488
+ of BOTH parties reconstructs a full bidirectional thread with bodies (the
489
+ sender's ``sent/`` copy is unstamped, so we ignore it).
490
+ """
491
+ cfg = self.cfg
492
+ if name == "user":
493
+ return [(cfg.queue_dir / "user", "delivered")]
494
+ if name == "system":
495
+ return []
496
+ try:
497
+ a = cfg.get(name)
498
+ except Exception:
499
+ return []
500
+ mp = cfg.mail_paths(a)
501
+ return [
502
+ (cfg.queue_dir / name, "queued"),
503
+ (mp.inbox, "delivered"),
504
+ (mp.read, "read"),
505
+ (cfg.runtime / "archive" / name, "archived"),
506
+ ]
507
+
508
+ def _collect_thread(self, a_name: str, b_name: str) -> list:
509
+ """Every message exchanged between ``a_name`` and ``b_name``, time-sorted.
510
+
511
+ Deduped by message Id (the same id appears once per recipient copy).
512
+ ``direction`` is relative to ``a_name`` (``out`` = a->b, ``in`` = b->a);
513
+ ``status`` is where the message currently sits (queued/delivered/read).
514
+ """
515
+ seen: dict = {}
516
+ for d, status in self._incoming_dirs(a_name) + self._incoming_dirs(b_name):
517
+ if not d.exists():
518
+ continue
519
+ for f in sorted(d.iterdir()):
520
+ if not f.is_file() or f.name == "about.md":
521
+ continue
522
+ try:
523
+ text = f.read_text()
524
+ except OSError: # pragma: no cover - defensive only
525
+ continue
526
+ m = self._parse_msg(text)
527
+ frm, to = m["from"], m["to"]
528
+ if frm is None or to is None or {frm, to} != {a_name, b_name}:
529
+ continue
530
+ m["direction"] = "out" if frm == a_name else "in"
531
+ m["status"] = status
532
+ key = m["id"] or f"{d}/{f.name}"
533
+ seen.setdefault(key, m)
534
+ return sorted(seen.values(), key=lambda m: (m["time"] or "", m["id"] or ""))
535
+
536
+ def _unread_from(self, agent_name: str, peer: str) -> int:
537
+ """How many messages from *peer* still sit unread (inbox/queue) for *agent*."""
538
+ cfg = self.cfg
539
+ try:
540
+ a = cfg.get(agent_name)
541
+ except Exception: # pragma: no cover - callers pass known agents
542
+ return 0
543
+ mp = cfg.mail_paths(a)
544
+ n = 0
545
+ for d in (mp.inbox, cfg.queue_dir / agent_name):
546
+ if not d.exists():
547
+ continue
548
+ for f in d.iterdir():
549
+ if f.is_file() and f.name != "about.md":
550
+ if self._parse_msg(f.read_text())["from"] == peer:
551
+ n += 1
552
+ return n
553
+
554
+ # -- API: contacts (the mail-app folder list) -----------------------------
555
+
556
+ def _api_contacts(self) -> None:
557
+ qs = parse_qs(urlparse(self.path).query)
558
+ name = qs.get("agent", [None])[0]
559
+ if not name:
560
+ self._send_json(400, {"error": "missing agent"})
561
+ return
562
+ try:
563
+ a = self.cfg.get(name)
564
+ except Exception:
565
+ self._send_json(404, {"error": "unknown agent"})
566
+ return
567
+ # Candidates: the ACL (agents + maybe user), system (always reachable to
568
+ # the agent), and anyone already seen in the agent's received mail.
569
+ names = set(a.can_talk_to)
570
+ names.add("system")
571
+ names.discard(name)
572
+ for d, _status in self._incoming_dirs(name):
573
+ if not d.exists():
574
+ continue
575
+ for f in d.iterdir():
576
+ if not f.is_file() or f.name == "about.md":
577
+ continue
578
+ m = self._parse_msg(f.read_text())
579
+ for who in (m["from"], m["to"]):
580
+ if who and who != name:
581
+ names.add(who)
582
+ contacts = []
583
+ for n in sorted(names):
584
+ thread = self._collect_thread(name, n)
585
+ last = thread[-1] if thread else None
586
+ preview = ""
587
+ if last and last["body"].strip():
588
+ preview = last["body"].strip().splitlines()[0][:80]
589
+ contacts.append(
590
+ {
591
+ "name": n,
592
+ "kind": "user" if n == "user" else "system" if n == "system" else "agent",
593
+ "count": len(thread),
594
+ "unread": self._unread_from(name, n),
595
+ "last_time": last["time"] if last else None,
596
+ "last_preview": preview,
597
+ }
598
+ )
599
+ self._send_json(200, {"agent": name, "contacts": contacts})
600
+
601
+ # -- API: thread ----------------------------------------------------------
602
+
603
+ def _api_thread(self) -> None:
604
+ qs = parse_qs(urlparse(self.path).query)
605
+ name = qs.get("agent", [None])[0]
606
+ peer = qs.get("peer", [None])[0]
607
+ if not name or not peer:
608
+ self._send_json(400, {"error": "missing agent/peer"})
609
+ return
610
+ try:
611
+ self.cfg.get(name)
612
+ except Exception:
613
+ self._send_json(404, {"error": "unknown agent"})
614
+ return
615
+ if peer not in ("user", "system") and peer not in self.cfg.names():
616
+ self._send_json(404, {"error": "unknown peer"})
617
+ return
618
+ self._send_json(
619
+ 200,
620
+ {"agent": name, "peer": peer, "messages": self._collect_thread(name, peer)},
621
+ )
622
+
623
+ # -- API: config (raw settings + agents for the editor) -------------------
624
+
625
+ def _api_config(self) -> None:
626
+ raw = reconcile.load_raw(self.cfg.path)
627
+ self._send_json(
628
+ 200,
629
+ {
630
+ "path": str(self.cfg.path),
631
+ "swarm": raw.get("swarm") or {},
632
+ "defaults": raw.get("defaults") or {},
633
+ "agents": raw.get("agents") or [],
634
+ "user_available": bool(self.cfg.user_available),
635
+ "warnings": list(self.cfg.warnings),
636
+ },
637
+ )
638
+
639
+ def _api_availability(self) -> None:
640
+ self._send_json(200, {"available": bool(self.cfg.user_available)})
641
+
642
+ # -- API: send ------------------------------------------------------------
643
+
644
+ def _api_send(self, raw: bytes) -> None:
645
+ try:
646
+ data = json.loads(raw.decode()) if raw else {}
647
+ except json.JSONDecodeError:
648
+ self._send_json(400, {"error": "invalid json"})
649
+ return
650
+ to = data.get("to")
651
+ text = data.get("text")
652
+ if not to or not isinstance(text, str) or text == "":
653
+ self._send_json(400, {"error": "missing to/text"})
654
+ return
655
+ try:
656
+ mail.send_as_user(self.cfg, to, text)
657
+ except Exception as exc:
658
+ self._send_json(400, {"error": str(exc)})
659
+ return
660
+ self._send_json(200, {"ok": True, "to": to})
661
+
662
+ # -- POST helpers ---------------------------------------------------------
663
+
664
+ def _json_body(self, raw: bytes):
665
+ """Decode a JSON request body; on error send 400 and return ``None``."""
666
+ try:
667
+ return json.loads(raw.decode()) if raw else {}
668
+ except json.JSONDecodeError:
669
+ self._send_json(400, {"error": "invalid json"})
670
+ return None
671
+
672
+ # -- API: type (direct pane input, bypasses the mailroom) -----------------
673
+
674
+ def _api_type(self, raw: bytes) -> None:
675
+ data = self._json_body(raw)
676
+ if data is None:
677
+ return
678
+ to = data.get("agent") or data.get("to")
679
+ text = data.get("text")
680
+ if not to or not isinstance(text, str) or text == "":
681
+ self._send_json(400, {"error": "missing agent/text"})
682
+ return
683
+ try:
684
+ a = self.cfg.get(to)
685
+ except Exception:
686
+ self._send_json(404, {"error": "unknown agent"})
687
+ return
688
+ try:
689
+ ok = tmux.paste_into(self.cfg, a.session, text)
690
+ except tmux.SwarmError as exc:
691
+ self._send_json(400, {"error": str(exc)})
692
+ return
693
+ self._send_json(200, {"ok": bool(ok), "agent": to})
694
+
695
+ def _api_key(self, raw: bytes) -> None:
696
+ data = self._json_body(raw)
697
+ if data is None:
698
+ return
699
+ to = data.get("agent") or data.get("to")
700
+ key = data.get("key")
701
+ if not to or not isinstance(key, str) or key == "":
702
+ self._send_json(400, {"error": "missing agent/key"})
703
+ return
704
+ try:
705
+ a = self.cfg.get(to)
706
+ except Exception:
707
+ self._send_json(404, {"error": "unknown agent"})
708
+ return
709
+ try:
710
+ ok = tmux.send_key(self.cfg, a.session, key)
711
+ except tmux.SwarmError as exc:
712
+ self._send_json(400, {"error": str(exc)})
713
+ return
714
+ self._send_json(200, {"ok": bool(ok), "agent": to, "key": key})
715
+
716
+ def _api_up(self, raw: bytes) -> None:
717
+ data = self._json_body(raw)
718
+ if data is None:
719
+ return
720
+ name = data.get("agent") or data.get("name")
721
+ if not name:
722
+ self._send_json(400, {"error": "missing agent"})
723
+ return
724
+ if name not in self.cfg.names():
725
+ self._send_json(404, {"error": "unknown agent"})
726
+ return
727
+ try:
728
+ started = reconcile.start_one(self.cfg, name)
729
+ except Exception as exc:
730
+ self._send_json(400, {"error": str(exc)})
731
+ return
732
+ self._send_json(200, {"ok": True, "agent": name, "started": bool(started)})
733
+
734
+ def _api_down(self, raw: bytes) -> None:
735
+ data = self._json_body(raw)
736
+ if data is None:
737
+ return
738
+ name = data.get("agent") or data.get("name")
739
+ if not name:
740
+ self._send_json(400, {"error": "missing agent"})
741
+ return
742
+ if name not in self.cfg.names():
743
+ self._send_json(404, {"error": "unknown agent"})
744
+ return
745
+ try:
746
+ stopped = reconcile.stop_one(self.cfg, name)
747
+ except Exception as exc:
748
+ self._send_json(400, {"error": str(exc)})
749
+ return
750
+ self._send_json(200, {"ok": True, "agent": name, "stopped": bool(stopped)})
751
+
752
+ # -- API: config (persist swarm settings) ---------------------------------
753
+
754
+ def _api_config_post(self, raw: bytes) -> None:
755
+ data = self._json_body(raw)
756
+ if data is None:
757
+ return
758
+ settings = data.get("swarm")
759
+ if not isinstance(settings, dict) or not settings:
760
+ self._send_json(400, {"error": "missing swarm settings"})
761
+ return
762
+ try:
763
+ new_cfg = reconcile.edit_swarm(self.cfg, **settings)
764
+ except Exception as exc:
765
+ self._send_json(400, {"error": str(exc)})
766
+ return
767
+ UIHandler.cfg = new_cfg
768
+ self._send_json(200, {"ok": True, "swarm": reconcile.load_raw(new_cfg.path).get("swarm") or {}})
769
+
770
+ # -- API: availability (toggle + persist) ---------------------------------
771
+
772
+ def _api_availability_post(self, raw: bytes) -> None:
773
+ data = self._json_body(raw)
774
+ if data is None:
775
+ return
776
+ val = data.get("available")
777
+ if not isinstance(val, bool):
778
+ self._send_json(400, {"error": "missing available (bool)"})
779
+ return
780
+ # A validated bool always round-trips through the loader, so this never
781
+ # produces an invalid config -- no error branch to guard.
782
+ new_cfg = reconcile.edit_swarm(self.cfg, user_available=val)
783
+ mail.set_user_available(new_cfg, val)
784
+ UIHandler.cfg = new_cfg
785
+ self._send_json(200, {"ok": True, "available": val})
786
+
787
+ # -- API: agent add / edit / remove ---------------------------------------
788
+
789
+ def _api_agent_add(self, raw: bytes) -> None:
790
+ data = self._json_body(raw)
791
+ if data is None:
792
+ return
793
+ name = data.get("name")
794
+ command = data.get("command")
795
+ if not name or not command:
796
+ self._send_json(400, {"error": "missing name/command"})
797
+ return
798
+ can = data.get("can_talk_to")
799
+ if can is None:
800
+ can = []
801
+ extra = {}
802
+ for k in ("capture", "boot_delay_ms", "periodically_ping_seconds",
803
+ "periodically_ping_message"):
804
+ if data.get(k) not in (None, ""):
805
+ extra[k] = data[k]
806
+ try:
807
+ new_cfg = reconcile.add_agent(
808
+ self.cfg,
809
+ name,
810
+ data.get("type") or "claude",
811
+ command,
812
+ can,
813
+ role=data.get("role") or "",
814
+ workdir=data.get("workdir"),
815
+ **extra,
816
+ )
817
+ except Exception as exc:
818
+ self._send_json(400, {"error": str(exc)})
819
+ return
820
+ UIHandler.cfg = new_cfg
821
+ mail.init_mailboxes(new_cfg)
822
+ self._send_json(200, {"ok": True, "name": name})
823
+
824
+ def _api_agent_edit(self, raw: bytes) -> None:
825
+ data = self._json_body(raw)
826
+ if data is None:
827
+ return
828
+ name = data.get("name")
829
+ fields = data.get("fields")
830
+ if not name or not isinstance(fields, dict) or not fields:
831
+ self._send_json(400, {"error": "missing name/fields"})
832
+ return
833
+ # reconcile.edit_agent coerces each value from its str form; a list
834
+ # can_talk_to must arrive as a comma string, or ``*`` for the wildcard.
835
+ clean = {}
836
+ for k, v in fields.items():
837
+ clean[k] = ",".join(v) if k == "can_talk_to" and isinstance(v, list) else v
838
+ try:
839
+ new_cfg = reconcile.edit_agent(self.cfg, name, **clean)
840
+ except Exception as exc:
841
+ self._send_json(400, {"error": str(exc)})
842
+ return
843
+ UIHandler.cfg = new_cfg
844
+ mail.init_mailboxes(new_cfg)
845
+ self._send_json(200, {"ok": True, "name": name})
846
+
847
+ def _api_agent_remove(self, raw: bytes) -> None:
848
+ data = self._json_body(raw)
849
+ if data is None:
850
+ return
851
+ name = data.get("name")
852
+ if not name:
853
+ self._send_json(400, {"error": "missing name"})
854
+ return
855
+ if name not in self.cfg.names():
856
+ self._send_json(404, {"error": "unknown agent"})
857
+ return
858
+ # Stop the session first so it isn't orphaned, then drop it from config.
859
+ a = self.cfg.get(name)
860
+ if tmux.session_exists(a.session):
861
+ tmux.tmux("kill-session", "-t", f"={a.session}", check=False, capture=True)
862
+ 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.
865
+ new_cfg = reconcile.remove_agent(self.cfg, name)
866
+ except Exception as exc:
867
+ self._send_json(400, {"error": str(exc)})
868
+ return
869
+ UIHandler.cfg = new_cfg
870
+ self._send_json(200, {"ok": True, "name": name})
871
+
872
+ # -- API: telegram bridge -------------------------------------------------
873
+
874
+ def _api_telegram(self) -> None:
875
+ """Report the Telegram config (never the raw token) + poller state."""
876
+ tg = self.cfg.telegram
877
+ self._send_json(
878
+ 200,
879
+ {
880
+ "enabled": bool(tg.enabled),
881
+ "has_token": bool(tg.bot_token),
882
+ "chat_id": tg.chat_id,
883
+ "mirror": tg.mirror,
884
+ "mirror_user": bool(tg.mirror_user),
885
+ "mirror_system": bool(tg.mirror_system),
886
+ "polling": _tg_poller is not None,
887
+ "agents": self.cfg.names(),
888
+ },
889
+ )
890
+
891
+ def _api_telegram_post(self, raw: bytes) -> None:
892
+ data = self._json_body(raw)
893
+ if data is None:
894
+ return
895
+ fields = {}
896
+ if "enabled" in data:
897
+ fields["enabled"] = bool(data["enabled"])
898
+ # Only overwrite the token when a fresh non-empty one is supplied, so the
899
+ # editor can leave it blank to keep the stored secret.
900
+ if data.get("bot_token"):
901
+ fields["bot_token"] = str(data["bot_token"])
902
+ if "chat_id" in data:
903
+ fields["chat_id"] = str(data["chat_id"])
904
+ if "mirror" in data:
905
+ fields["mirror"] = data["mirror"]
906
+ if "mirror_user" in data:
907
+ fields["mirror_user"] = bool(data["mirror_user"])
908
+ if "mirror_system" in data:
909
+ fields["mirror_system"] = bool(data["mirror_system"])
910
+ if not fields:
911
+ self._send_json(400, {"error": "no telegram settings given"})
912
+ return
913
+ try:
914
+ new_cfg = reconcile.edit_telegram(self.cfg, **fields)
915
+ except Exception as exc:
916
+ self._send_json(400, {"error": str(exc)})
917
+ return
918
+ UIHandler.cfg = new_cfg
919
+ # If a reply poller is running, restart it so it picks up the new config.
920
+ global _tg_poller
921
+ with _tg_lock:
922
+ if _tg_poller is not None:
923
+ _tg_poller.stop()
924
+ _tg_poller = telegram.start_poller(new_cfg)
925
+ polling = _tg_poller is not None
926
+ self._send_json(
927
+ 200,
928
+ {"ok": True, "enabled": bool(new_cfg.telegram.enabled),
929
+ "has_token": bool(new_cfg.telegram.bot_token), "polling": polling},
930
+ )
931
+
932
+ def _api_telegram_test(self, raw: bytes) -> None:
933
+ data = self._json_body(raw)
934
+ if data is None:
935
+ return
936
+ if not telegram.is_enabled(self.cfg):
937
+ self._send_json(400, {"error": "telegram is not enabled / not fully configured"})
938
+ return
939
+ try:
940
+ telegram.send_message(self.cfg, data.get("text") or "✅ Agentainer test message")
941
+ except telegram.TelegramError as exc:
942
+ self._send_json(400, {"error": str(exc)})
943
+ return
944
+ self._send_json(200, {"ok": True})
945
+
946
+ def _api_telegram_poll(self, raw: bytes) -> None:
947
+ data = self._json_body(raw)
948
+ if data is None:
949
+ return
950
+ global _tg_poller
951
+ run = bool(data.get("run"))
952
+ if run and not telegram.is_enabled(self.cfg):
953
+ self._send_json(400, {"error": "telegram is not enabled / not fully configured"})
954
+ return
955
+ with _tg_lock:
956
+ if run:
957
+ if _tg_poller is None:
958
+ _tg_poller = telegram.start_poller(self.cfg)
959
+ elif _tg_poller is not None:
960
+ _tg_poller.stop()
961
+ _tg_poller = None
962
+ polling = _tg_poller is not None
963
+ self._send_json(200, {"ok": True, "polling": polling})
964
+
965
+
966
+ def run_server(
967
+ cfg,
968
+ token: str,
969
+ host: str = "127.0.0.1",
970
+ port: int = 0,
971
+ background: bool = True,
972
+ ui_dir=None,
973
+ ):
974
+ """Bind and serve the Agentainer UI control plane.
975
+
976
+ Args:
977
+ cfg: a loaded ``SwarmConfig`` (the source of truth for state).
978
+ token: the auth token required for every API call / POST. May be empty
979
+ ONLY when *host* is loopback (127.0.0.1 / localhost / ::1).
980
+ host: bind interface. Defaults to ``127.0.0.1``. NEVER ``0.0.0.0``
981
+ without a token -- a non-loopback bind with an empty token raises
982
+ ``ValueError``.
983
+ port: port to bind. ``0`` (default) lets the OS pick a free port; the
984
+ real port is reported on the returned ``ServerHandle.port``.
985
+ background: if True (default), serve in a daemon thread and return a
986
+ ``ServerHandle`` immediately (call ``.shutdown()`` to stop). If
987
+ False, block in ``serve_forever()`` and return the handle only
988
+ after the server is stopped (use ``ui._last_server.shutdown()``
989
+ from another thread, or the returned handle once it returns).
990
+ ui_dir: override the static-asset directory (defaults to ``<repo>/ui``).
991
+ Mainly for testing.
992
+
993
+ Returns:
994
+ ServerHandle with ``.port`` (real bound port) and ``.shutdown()``.
995
+ """
996
+ if not _is_loopback(host) and not token:
997
+ raise ValueError("a token is required to bind to a non-loopback host")
998
+
999
+ ui_path = Path(ui_dir) if ui_dir is not None else _DEFAULT_UI_DIR
1000
+ UIHandler.cfg = cfg
1001
+ UIHandler.token = token
1002
+ UIHandler.ui_dir = ui_path
1003
+
1004
+ server = ThreadingHTTPServer((host, port), UIHandler)
1005
+ real_port = server.server_address[1]
1006
+
1007
+ global _last_server
1008
+ _last_server = server
1009
+
1010
+ handle = ServerHandle(server, real_port, None)
1011
+ if background:
1012
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
1013
+ thread.start()
1014
+ handle.thread = thread
1015
+ return handle
1016
+
1017
+ # Foreground: block until shutdown() is called from another thread, then
1018
+ # fall through and return the handle.
1019
+ server.serve_forever()
1020
+ return handle