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/ui.py ADDED
@@ -0,0 +1,1219 @@
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
+ GET /api/templates -> bundled example swarms (onboarding, empty swarm)
36
+ GET /api/rate?window= -> opt-in per-agent messages/min over last `window` min
37
+ POST /api/send -> body {"to","text"} -> mail.send_as_user
38
+ POST /api/type -> body {"agent","text"} -> tmux.paste_into (direct pane input)
39
+ POST /api/key -> body {"agent","key"} -> tmux.send_key (Escape, C-c, ...)
40
+ POST /api/up -> body {"agent"} -> reconcile.start_one (launch if down)
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)
44
+ POST /api/config -> body {"swarm": {...}} -> persist swarm settings to YAML
45
+ POST /api/availability -> body {"available": bool} -> toggle + persist
46
+ POST /api/agent/add -> body {"name","type","command",...} -> add + persist
47
+ POST /api/agent/edit -> body {"name","fields": {...}} -> edit + persist
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
50
+
51
+ Every mutation rewrites ``agentainer.yaml`` (via lib/reconcile's stdlib emitter,
52
+ so the no-PyYAML path stays live) and swaps ``UIHandler.cfg`` for the reloaded
53
+ config so subsequent requests see the change.
54
+
55
+ Static assets are token-EXEMPT so the login page can load; every API call and
56
+ every POST requires the token (query param ``?token=`` or
57
+ ``Authorization: Bearer <token>`` header).
58
+
59
+ Branding: "swarm" is retired -- it's Agentainer everywhere (decision D21).
60
+ """
61
+
62
+ from __future__ import annotations
63
+
64
+ import json
65
+ import sys
66
+ import threading
67
+ from pathlib import Path
68
+ from urllib.parse import parse_qs, urlparse
69
+
70
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
71
+
72
+ # The lib modules import one another by bare name (config, tmux, ...); the test
73
+ # harness and CLI put ``lib/`` on ``sys.path``. Make that true for this module
74
+ # too, so it is importable standalone (e.g. ``python3 -m lib.ui`` is not needed,
75
+ # but ``import ui`` must work from anywhere lib/ is on the path).
76
+ _LIB = Path(__file__).resolve().parent
77
+ if str(_LIB) not in sys.path:
78
+ sys.path.insert(0, str(_LIB))
79
+
80
+ import config # noqa: E402
81
+ import mail # noqa: E402
82
+ import reconcile # noqa: E402
83
+ import telegram # noqa: E402
84
+ import tmux # noqa: E402
85
+ import turn # noqa: E402
86
+
87
+ # supervisor is imported lazily inside _api_status so a checkout that lacks it
88
+ # (or a test that hides it) degrades gracefully instead of crashing the server.
89
+
90
+
91
+ # The directory holding the static UI assets (index.html, app.js). Defaults to
92
+ # the repo's ``ui/`` directory; ``run_server`` accepts an override (used by tests
93
+ # to point at a temp dir and exercise the missing-asset branch).
94
+ _DEFAULT_UI_DIR = _LIB.parent / "ui"
95
+
96
+ # Hosts we treat as loopback. Binding any OTHER host requires a token, because
97
+ # the UI can start processes and type into agents.
98
+ _LOOPBACK_HOSTS = ("127.0.0.1", "localhost", "::1", "0:0:0:0:0:0:0:1")
99
+
100
+ # Module-global handle to the most recently created server. Lets the foreground
101
+ # ``serve`` CLI command (and the foreground branch test) stop a server that is
102
+ # blocking in ``serve_forever`` from another thread.
103
+ _last_server = None
104
+
105
+ # Module-global Telegram reply poller (started/stopped from the UI). Only one
106
+ # runs per serve process; None when polling is off. ``ThreadingHTTPServer`` hands
107
+ # each request to its own thread, so all start/stop/restart of the poller goes
108
+ # through ``_tg_lock`` -- otherwise two concurrent poll requests could each see
109
+ # ``None`` and start (and leak) a second poller.
110
+ _tg_poller = None
111
+ _tg_lock = threading.Lock()
112
+
113
+
114
+ def _is_loopback(host: str) -> bool:
115
+ """True iff *host* resolves to the local machine only (safe without a token)."""
116
+ return host in _LOOPBACK_HOSTS
117
+
118
+
119
+ class ServerHandle:
120
+ """A started UI server: know the real port + stop it cleanly.
121
+
122
+ ``run_server`` returns one of these. Use ``.port`` (the real bound port, even
123
+ when you passed ``port=0``) and ``.shutdown()`` to stop the background thread.
124
+ """
125
+
126
+ def __init__(self, server: ThreadingHTTPServer, port: int, thread):
127
+ self.server = server
128
+ self.port = port
129
+ self.thread = thread
130
+
131
+ @property
132
+ def url(self) -> str:
133
+ host = self.server.server_address[0]
134
+ return f"http://{host}:{self.port}"
135
+
136
+ def shutdown(self) -> None:
137
+ """Stop the HTTP server and join its background thread (best effort)."""
138
+ global _tg_poller
139
+ with _tg_lock:
140
+ if _tg_poller is not None:
141
+ _tg_poller.stop()
142
+ _tg_poller = None
143
+ self.server.shutdown()
144
+ if self.thread is not None:
145
+ self.thread.join(timeout=5)
146
+
147
+ def __enter__(self) -> "ServerHandle":
148
+ return self
149
+
150
+ def __exit__(self, *exc) -> None:
151
+ self.shutdown()
152
+
153
+
154
+ class UIHandler(BaseHTTPRequestHandler):
155
+ """Thin request handler over the core modules.
156
+
157
+ ``cfg``, ``token`` and ``ui_dir`` are set as class attributes on
158
+ ``UIHandler`` by ``run_server`` before the server starts accepting traffic.
159
+ """
160
+
161
+ cfg = None
162
+ token = None
163
+ ui_dir = None
164
+ # Keep the test output quiet; the orchestrator has its own logs.
165
+ protocol_version = "HTTP/1.0"
166
+
167
+ # -- low-level responders -------------------------------------------------
168
+
169
+ def log_message(self, format, *args): # noqa: A002 - BaseHTTPRequestHandler API
170
+ # Suppress the default stderr logging; tests assert on responses instead.
171
+ pass
172
+
173
+ def _send_json(self, code: int, obj: dict) -> None:
174
+ body = json.dumps(obj).encode()
175
+ self.send_response(code)
176
+ self.send_header("Content-Type", "application/json")
177
+ self.send_header("Content-Length", str(len(body)))
178
+ self.end_headers()
179
+ self.wfile.write(body)
180
+
181
+ def _send_text(self, code: int, text: str, content_type: str) -> None:
182
+ body = text.encode()
183
+ self.send_response(code)
184
+ self.send_header("Content-Type", content_type)
185
+ self.send_header("Content-Length", str(len(body)))
186
+ self.end_headers()
187
+ self.wfile.write(body)
188
+
189
+ # -- auth -----------------------------------------------------------------
190
+
191
+ def _token_valid(self) -> bool:
192
+ """Accept the token via ``?token=`` or ``Authorization: Bearer <token>``."""
193
+ parsed = urlparse(self.path)
194
+ qs = parse_qs(parsed.query)
195
+ if "token" in qs and qs["token"][0] == self.token:
196
+ return True
197
+ auth = self.headers.get("Authorization", "")
198
+ if auth.startswith("Bearer "):
199
+ if auth[len("Bearer "):].strip() == self.token:
200
+ return True
201
+ return False
202
+
203
+ def _auth_required(self) -> bool:
204
+ """Static assets are token-exempt; everything else needs the token."""
205
+ path = urlparse(self.path).path
206
+ if path in ("/", "/index.html", "/app.js"):
207
+ return False
208
+ return True
209
+
210
+ # -- routing --------------------------------------------------------------
211
+
212
+ def do_GET(self) -> None:
213
+ if self._auth_required() and not self._token_valid():
214
+ self._send_json(401, {"error": "unauthorized"})
215
+ return
216
+ path = urlparse(self.path).path
217
+ if path in ("/", "/index.html"):
218
+ self._serve_static("index.html", "text/html; charset=utf-8")
219
+ elif path == "/app.js":
220
+ self._serve_static("app.js", "text/javascript; charset=utf-8")
221
+ elif path == "/api/status":
222
+ self._api_status()
223
+ elif path == "/api/agents":
224
+ self._api_agents()
225
+ elif path == "/api/agent":
226
+ self._api_agent()
227
+ elif path == "/api/contacts":
228
+ self._api_contacts()
229
+ elif path == "/api/thread":
230
+ self._api_thread()
231
+ elif path == "/api/logs":
232
+ self._api_logs()
233
+ elif path == "/api/inbox":
234
+ self._api_inbox()
235
+ elif path == "/api/queue":
236
+ self._api_queue()
237
+ elif path == "/api/pane":
238
+ self._api_pane()
239
+ elif path == "/api/config":
240
+ self._api_config()
241
+ elif path == "/api/availability":
242
+ self._api_availability()
243
+ elif path == "/api/telegram":
244
+ self._api_telegram()
245
+ elif path == "/api/templates":
246
+ self._api_templates()
247
+ elif path == "/api/rate":
248
+ self._api_rate()
249
+ else:
250
+ self._send_json(404, {"error": "not found"})
251
+
252
+ def do_POST(self) -> None:
253
+ length = int(self.headers.get("Content-Length", 0) or 0)
254
+ raw = self.rfile.read(length) if length > 0 else b""
255
+ if not self._token_valid():
256
+ self._send_json(401, {"error": "unauthorized"})
257
+ return
258
+ path = urlparse(self.path).path
259
+ if path == "/api/send":
260
+ self._api_send(raw)
261
+ elif path == "/api/type":
262
+ self._api_type(raw)
263
+ elif path == "/api/key":
264
+ self._api_key(raw)
265
+ elif path == "/api/up":
266
+ self._api_up(raw)
267
+ elif path == "/api/down":
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)
273
+ elif path == "/api/config":
274
+ self._api_config_post(raw)
275
+ elif path == "/api/availability":
276
+ self._api_availability_post(raw)
277
+ elif path == "/api/agent/add":
278
+ self._api_agent_add(raw)
279
+ elif path == "/api/agent/edit":
280
+ self._api_agent_edit(raw)
281
+ elif path == "/api/agent/remove":
282
+ self._api_agent_remove(raw)
283
+ elif path == "/api/telegram":
284
+ self._api_telegram_post(raw)
285
+ elif path == "/api/templates/apply":
286
+ self._api_templates_apply(raw)
287
+ elif path == "/api/telegram/test":
288
+ self._api_telegram_test(raw)
289
+ elif path == "/api/telegram/poll":
290
+ self._api_telegram_poll(raw)
291
+ else:
292
+ self._send_json(404, {"error": "not found"})
293
+
294
+ # -- static ---------------------------------------------------------------
295
+
296
+ def _serve_static(self, name: str, content_type: str) -> None:
297
+ p = Path(self.ui_dir) / name
298
+ if not p.exists():
299
+ self._send_text(404, "not found", "text/plain")
300
+ return
301
+ self._send_text(200, p.read_text(), content_type)
302
+
303
+ # -- API: status ----------------------------------------------------------
304
+
305
+ def _supervisor_alive(self):
306
+ """Lazily import supervisor so an absent module degrades to None."""
307
+ try:
308
+ import supervisor as _supervisor # noqa: F401
309
+ except Exception:
310
+ return None
311
+ return _supervisor.supervisor_alive(self.cfg)
312
+
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:
349
+ """The live status row for one agent (shared by /api/status + /api/agent)."""
350
+ cfg = self.cfg
351
+ if pending is None:
352
+ pending = self._pending_user_senders()
353
+ mp = cfg.mail_paths(a)
354
+ queue_dir = cfg.queue_dir / a.name
355
+ queue_depth = (
356
+ len([f for f in queue_dir.iterdir() if f.is_file()])
357
+ if queue_dir.exists()
358
+ else 0
359
+ )
360
+ inbox_dir = mp.inbox
361
+ unread = (
362
+ len([f for f in inbox_dir.iterdir() if f.is_file()])
363
+ if inbox_dir.exists()
364
+ else 0
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)
369
+ return {
370
+ "name": a.name,
371
+ "type": a.type,
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,
377
+ "queue_depth": queue_depth,
378
+ "unread": unread,
379
+ "can_talk_to": a.can_talk_to,
380
+ }
381
+
382
+ def _api_status(self) -> None:
383
+ cfg = self.cfg
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
+ )
390
+ self._send_json(
391
+ 200,
392
+ {
393
+ "name": cfg.name,
394
+ "root": str(cfg.root),
395
+ "user_available": bool(cfg.user_available),
396
+ "supervisor_alive": self._supervisor_alive(),
397
+ "attention": attention,
398
+ "agents": agents,
399
+ },
400
+ )
401
+
402
+ # -- API: agents ----------------------------------------------------------
403
+
404
+ def _api_agents(self) -> None:
405
+ agents = [
406
+ {"name": a.name, "type": a.type, "can_talk_to": a.can_talk_to}
407
+ for a in self.cfg.agents
408
+ ]
409
+ self._send_json(200, {"agents": agents})
410
+
411
+ # -- API: logs ------------------------------------------------------------
412
+
413
+ def _api_logs(self) -> None:
414
+ qs = parse_qs(urlparse(self.path).query)
415
+ agent = qs.get("agent", [None])[0]
416
+ try:
417
+ n = int(qs.get("n", ["50"])[0])
418
+ except ValueError:
419
+ n = 50
420
+ if agent:
421
+ logfile = self.cfg.log_dir / f"{agent}.jsonl"
422
+ else:
423
+ logfile = self.cfg.log_dir / "agentainer.jsonl"
424
+ records = []
425
+ if logfile.exists():
426
+ lines = logfile.read_text().splitlines()
427
+ for line in lines[-n:]:
428
+ line = line.strip()
429
+ if not line:
430
+ continue
431
+ try:
432
+ records.append(json.loads(line))
433
+ except json.JSONDecodeError:
434
+ records.append({"raw": line})
435
+ self._send_json(200, {"agent": agent, "logs": records})
436
+
437
+ # -- API: inbox -----------------------------------------------------------
438
+
439
+ def _api_inbox(self) -> None:
440
+ qs = parse_qs(urlparse(self.path).query)
441
+ agent = qs.get("agent", [None])[0]
442
+ if not agent:
443
+ self._send_json(400, {"error": "missing agent"})
444
+ return
445
+ try:
446
+ a = self.cfg.get(agent)
447
+ except Exception:
448
+ self._send_json(404, {"error": "unknown agent"})
449
+ return
450
+ mp = self.cfg.mail_paths(a)
451
+ msgs = []
452
+ if mp.inbox.exists():
453
+ for f in sorted(mp.inbox.iterdir()):
454
+ if f.is_file():
455
+ msgs.append({"file": f.name, "text": f.read_text()})
456
+ self._send_json(200, {"agent": agent, "inbox": msgs})
457
+
458
+ # -- API: queue -----------------------------------------------------------
459
+
460
+ def _api_queue(self) -> None:
461
+ qs = parse_qs(urlparse(self.path).query)
462
+ agent = qs.get("agent", [None])[0]
463
+ if not agent:
464
+ self._send_json(400, {"error": "missing agent"})
465
+ return
466
+ try:
467
+ self.cfg.get(agent)
468
+ except Exception:
469
+ self._send_json(404, {"error": "unknown agent"})
470
+ return
471
+ msgs = []
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})
478
+ self._send_json(200, {"agent": agent, "queue": msgs})
479
+
480
+ # -- API: pane (terminal snapshot) ---------------------------------------
481
+
482
+ def _api_pane(self) -> None:
483
+ qs = parse_qs(urlparse(self.path).query)
484
+ agent = qs.get("agent", [None])[0]
485
+ if not agent:
486
+ self._send_json(400, {"error": "missing agent"})
487
+ return
488
+ try:
489
+ a = self.cfg.get(agent)
490
+ except Exception:
491
+ self._send_json(404, {"error": "unknown agent"})
492
+ return
493
+ # capture_pane returns "" when the session is down / tmux errors, so the
494
+ # UI just renders an empty pane rather than failing the request.
495
+ self._send_json(200, {"agent": agent, "pane": tmux.capture_pane(self.cfg, a)})
496
+
497
+ # -- API: agent detail ----------------------------------------------------
498
+
499
+ def _api_agent(self) -> None:
500
+ qs = parse_qs(urlparse(self.path).query)
501
+ name = qs.get("agent", [None])[0]
502
+ if not name:
503
+ self._send_json(400, {"error": "missing agent"})
504
+ return
505
+ try:
506
+ a = self.cfg.get(name)
507
+ except Exception:
508
+ self._send_json(404, {"error": "unknown agent"})
509
+ return
510
+ d = self._agent_status(a)
511
+ d.update(
512
+ {
513
+ "command": a.command,
514
+ "role": a.role,
515
+ "workdir": str(a.workdir),
516
+ "capture": a.capture,
517
+ "session": a.session,
518
+ "periodically_ping_seconds": a.periodically_ping_seconds,
519
+ "periodically_ping_message": a.periodically_ping_message,
520
+ }
521
+ )
522
+ self._send_json(200, {"agent": d})
523
+
524
+ # -- mail-app helpers (thread reconstruction) -----------------------------
525
+
526
+ def _parse_msg(self, text: str) -> dict:
527
+ """Parse a stamped mail file into ``{from,to,id,time,body}``.
528
+
529
+ The model never writes headers -- the orchestrator stamps every routed
530
+ message (``mail.stamp_message``), so every *received* copy carries them.
531
+ """
532
+ parts = text.split("\n\n", 1)
533
+ body = parts[1] if len(parts) > 1 else text
534
+ return {
535
+ "from": mail._parse_header_field(text, "From"),
536
+ "to": mail._parse_header_field(text, "To"),
537
+ "id": mail._parse_header_field(text, "Id"),
538
+ "time": mail._parse_header_field(text, "Time"),
539
+ "body": body,
540
+ }
541
+
542
+ def _incoming_dirs(self, name: str) -> list:
543
+ """``(dir, status)`` pairs holding STAMPED messages *received* by ``name``.
544
+
545
+ Every routed message lands, stamped, in its recipient's queue/inbox/read
546
+ (and, if force-archived, the archive), and the folder it currently sits in
547
+ IS its delivery status: ``queued`` (waiting to be released) -> ``delivered``
548
+ (presented, unread) -> ``read`` (done). ``user`` is virtual -- messages to
549
+ it accumulate in its queue, which is effectively its inbox, so we label
550
+ those ``delivered``. ``system`` never receives. Scanning the recipient side
551
+ of BOTH parties reconstructs a full bidirectional thread with bodies (the
552
+ sender's ``sent/`` copy is unstamped, so we ignore it).
553
+ """
554
+ cfg = self.cfg
555
+ if name == "user":
556
+ return [(cfg.queue_dir / "user", "delivered")]
557
+ if name == "system":
558
+ return []
559
+ try:
560
+ a = cfg.get(name)
561
+ except Exception:
562
+ return []
563
+ mp = cfg.mail_paths(a)
564
+ return [
565
+ (cfg.queue_dir / name, "queued"),
566
+ (mp.inbox, "delivered"),
567
+ (mp.read, "read"),
568
+ (cfg.runtime / "archive" / name, "archived"),
569
+ ]
570
+
571
+ def _collect_thread(self, a_name: str, b_name: str) -> list:
572
+ """Every message exchanged between ``a_name`` and ``b_name``, time-sorted.
573
+
574
+ Deduped by message Id (the same id appears once per recipient copy).
575
+ ``direction`` is relative to ``a_name`` (``out`` = a->b, ``in`` = b->a);
576
+ ``status`` is where the message currently sits (queued/delivered/read).
577
+ """
578
+ seen: dict = {}
579
+ for d, status in self._incoming_dirs(a_name) + self._incoming_dirs(b_name):
580
+ if not d.exists():
581
+ continue
582
+ for f in sorted(d.iterdir()):
583
+ if not f.is_file() or f.name == "about.md":
584
+ continue
585
+ try:
586
+ text = f.read_text()
587
+ except OSError: # pragma: no cover - defensive only
588
+ continue
589
+ m = self._parse_msg(text)
590
+ frm, to = m["from"], m["to"]
591
+ if frm is None or to is None or {frm, to} != {a_name, b_name}:
592
+ continue
593
+ m["direction"] = "out" if frm == a_name else "in"
594
+ m["status"] = status
595
+ key = m["id"] or f"{d}/{f.name}"
596
+ seen.setdefault(key, m)
597
+ return sorted(seen.values(), key=lambda m: (m["time"] or "", m["id"] or ""))
598
+
599
+ def _unread_from(self, agent_name: str, peer: str) -> int:
600
+ """How many messages from *peer* still sit unread (inbox/queue) for *agent*."""
601
+ cfg = self.cfg
602
+ try:
603
+ a = cfg.get(agent_name)
604
+ except Exception: # pragma: no cover - callers pass known agents
605
+ return 0
606
+ mp = cfg.mail_paths(a)
607
+ n = 0
608
+ for d in (mp.inbox, cfg.queue_dir / agent_name):
609
+ if not d.exists():
610
+ continue
611
+ for f in d.iterdir():
612
+ if f.is_file() and f.name != "about.md":
613
+ if self._parse_msg(f.read_text())["from"] == peer:
614
+ n += 1
615
+ return n
616
+
617
+ # -- API: contacts (the mail-app folder list) -----------------------------
618
+
619
+ def _api_contacts(self) -> None:
620
+ qs = parse_qs(urlparse(self.path).query)
621
+ name = qs.get("agent", [None])[0]
622
+ if not name:
623
+ self._send_json(400, {"error": "missing agent"})
624
+ return
625
+ try:
626
+ a = self.cfg.get(name)
627
+ except Exception:
628
+ self._send_json(404, {"error": "unknown agent"})
629
+ return
630
+ # Candidates: the ACL (agents + maybe user), system (always reachable to
631
+ # the agent), and anyone already seen in the agent's received mail.
632
+ names = set(a.can_talk_to)
633
+ names.add("system")
634
+ names.discard(name)
635
+ for d, _status in self._incoming_dirs(name):
636
+ if not d.exists():
637
+ continue
638
+ for f in d.iterdir():
639
+ if not f.is_file() or f.name == "about.md":
640
+ continue
641
+ m = self._parse_msg(f.read_text())
642
+ for who in (m["from"], m["to"]):
643
+ if who and who != name:
644
+ names.add(who)
645
+ contacts = []
646
+ for n in sorted(names):
647
+ thread = self._collect_thread(name, n)
648
+ last = thread[-1] if thread else None
649
+ preview = ""
650
+ if last and last["body"].strip():
651
+ preview = last["body"].strip().splitlines()[0][:80]
652
+ contacts.append(
653
+ {
654
+ "name": n,
655
+ "kind": "user" if n == "user" else "system" if n == "system" else "agent",
656
+ "count": len(thread),
657
+ "unread": self._unread_from(name, n),
658
+ "last_time": last["time"] if last else None,
659
+ "last_preview": preview,
660
+ }
661
+ )
662
+ self._send_json(200, {"agent": name, "contacts": contacts})
663
+
664
+ # -- API: thread ----------------------------------------------------------
665
+
666
+ def _api_thread(self) -> None:
667
+ qs = parse_qs(urlparse(self.path).query)
668
+ name = qs.get("agent", [None])[0]
669
+ peer = qs.get("peer", [None])[0]
670
+ if not name or not peer:
671
+ self._send_json(400, {"error": "missing agent/peer"})
672
+ return
673
+ try:
674
+ self.cfg.get(name)
675
+ except Exception:
676
+ self._send_json(404, {"error": "unknown agent"})
677
+ return
678
+ if peer not in ("user", "system") and peer not in self.cfg.names():
679
+ self._send_json(404, {"error": "unknown peer"})
680
+ return
681
+ self._send_json(
682
+ 200,
683
+ {"agent": name, "peer": peer, "messages": self._collect_thread(name, peer)},
684
+ )
685
+
686
+ # -- API: config (raw settings + agents for the editor) -------------------
687
+
688
+ def _api_config(self) -> None:
689
+ raw = reconcile.load_raw(self.cfg.path)
690
+ self._send_json(
691
+ 200,
692
+ {
693
+ "path": str(self.cfg.path),
694
+ "swarm": raw.get("swarm") or {},
695
+ "defaults": raw.get("defaults") or {},
696
+ "agents": raw.get("agents") or [],
697
+ "user_available": bool(self.cfg.user_available),
698
+ "warnings": list(self.cfg.warnings),
699
+ },
700
+ )
701
+
702
+ def _api_availability(self) -> None:
703
+ self._send_json(200, {"available": bool(self.cfg.user_available)})
704
+
705
+ # -- API: send ------------------------------------------------------------
706
+
707
+ def _api_send(self, raw: bytes) -> None:
708
+ try:
709
+ data = json.loads(raw.decode()) if raw else {}
710
+ except json.JSONDecodeError:
711
+ self._send_json(400, {"error": "invalid json"})
712
+ return
713
+ to = data.get("to")
714
+ text = data.get("text")
715
+ if not to or not isinstance(text, str) or text == "":
716
+ self._send_json(400, {"error": "missing to/text"})
717
+ return
718
+ try:
719
+ mail.send_as_user(self.cfg, to, text)
720
+ except Exception as exc:
721
+ self._send_json(400, {"error": str(exc)})
722
+ return
723
+ self._send_json(200, {"ok": True, "to": to})
724
+
725
+ # -- POST helpers ---------------------------------------------------------
726
+
727
+ def _json_body(self, raw: bytes):
728
+ """Decode a JSON request body; on error send 400 and return ``None``."""
729
+ try:
730
+ return json.loads(raw.decode()) if raw else {}
731
+ except json.JSONDecodeError:
732
+ self._send_json(400, {"error": "invalid json"})
733
+ return None
734
+
735
+ # -- API: type (direct pane input, bypasses the mailroom) -----------------
736
+
737
+ def _api_type(self, raw: bytes) -> None:
738
+ data = self._json_body(raw)
739
+ if data is None:
740
+ return
741
+ to = data.get("agent") or data.get("to")
742
+ text = data.get("text")
743
+ if not to or not isinstance(text, str) or text == "":
744
+ self._send_json(400, {"error": "missing agent/text"})
745
+ return
746
+ try:
747
+ a = self.cfg.get(to)
748
+ except Exception:
749
+ self._send_json(404, {"error": "unknown agent"})
750
+ return
751
+ try:
752
+ ok = tmux.paste_into(self.cfg, a.session, text)
753
+ except tmux.SwarmError as exc:
754
+ self._send_json(400, {"error": str(exc)})
755
+ return
756
+ self._send_json(200, {"ok": bool(ok), "agent": to})
757
+
758
+ def _api_key(self, raw: bytes) -> None:
759
+ data = self._json_body(raw)
760
+ if data is None:
761
+ return
762
+ to = data.get("agent") or data.get("to")
763
+ key = data.get("key")
764
+ if not to or not isinstance(key, str) or key == "":
765
+ self._send_json(400, {"error": "missing agent/key"})
766
+ return
767
+ try:
768
+ a = self.cfg.get(to)
769
+ except Exception:
770
+ self._send_json(404, {"error": "unknown agent"})
771
+ return
772
+ try:
773
+ ok = tmux.send_key(self.cfg, a.session, key)
774
+ except tmux.SwarmError as exc:
775
+ self._send_json(400, {"error": str(exc)})
776
+ return
777
+ self._send_json(200, {"ok": bool(ok), "agent": to, "key": key})
778
+
779
+ def _api_up(self, raw: bytes) -> None:
780
+ data = self._json_body(raw)
781
+ if data is None:
782
+ return
783
+ name = data.get("agent") or data.get("name")
784
+ if not name:
785
+ self._send_json(400, {"error": "missing agent"})
786
+ return
787
+ if name not in self.cfg.names():
788
+ self._send_json(404, {"error": "unknown agent"})
789
+ return
790
+ try:
791
+ started = reconcile.start_one(self.cfg, name)
792
+ except Exception as exc:
793
+ self._send_json(400, {"error": str(exc)})
794
+ return
795
+ self._send_json(200, {"ok": True, "agent": name, "started": bool(started)})
796
+
797
+ def _api_down(self, raw: bytes) -> None:
798
+ data = self._json_body(raw)
799
+ if data is None:
800
+ return
801
+ name = data.get("agent") or data.get("name")
802
+ if not name:
803
+ self._send_json(400, {"error": "missing agent"})
804
+ return
805
+ if name not in self.cfg.names():
806
+ self._send_json(404, {"error": "unknown agent"})
807
+ return
808
+ try:
809
+ stopped = reconcile.stop_one(self.cfg, name)
810
+ except Exception as exc:
811
+ self._send_json(400, {"error": str(exc)})
812
+ return
813
+ self._send_json(200, {"ok": True, "agent": name, "stopped": bool(stopped)})
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
+
950
+ # -- API: config (persist swarm settings) ---------------------------------
951
+
952
+ def _api_config_post(self, raw: bytes) -> None:
953
+ data = self._json_body(raw)
954
+ if data is None:
955
+ return
956
+ settings = data.get("swarm")
957
+ if not isinstance(settings, dict) or not settings:
958
+ self._send_json(400, {"error": "missing swarm settings"})
959
+ return
960
+ try:
961
+ new_cfg = reconcile.edit_swarm(self.cfg, **settings)
962
+ except Exception as exc:
963
+ self._send_json(400, {"error": str(exc)})
964
+ return
965
+ UIHandler.cfg = new_cfg
966
+ self._send_json(200, {"ok": True, "swarm": reconcile.load_raw(new_cfg.path).get("swarm") or {}})
967
+
968
+ # -- API: availability (toggle + persist) ---------------------------------
969
+
970
+ def _api_availability_post(self, raw: bytes) -> None:
971
+ data = self._json_body(raw)
972
+ if data is None:
973
+ return
974
+ val = data.get("available")
975
+ if not isinstance(val, bool):
976
+ self._send_json(400, {"error": "missing available (bool)"})
977
+ return
978
+ # A validated bool always round-trips through the loader, so this never
979
+ # produces an invalid config -- no error branch to guard.
980
+ new_cfg = reconcile.edit_swarm(self.cfg, user_available=val)
981
+ mail.set_user_available(new_cfg, val)
982
+ UIHandler.cfg = new_cfg
983
+ self._send_json(200, {"ok": True, "available": val})
984
+
985
+ # -- API: agent add / edit / remove ---------------------------------------
986
+
987
+ def _api_agent_add(self, raw: bytes) -> None:
988
+ data = self._json_body(raw)
989
+ if data is None:
990
+ return
991
+ name = data.get("name")
992
+ command = data.get("command")
993
+ if not name or not command:
994
+ self._send_json(400, {"error": "missing name/command"})
995
+ return
996
+ can = data.get("can_talk_to")
997
+ if can is None:
998
+ can = []
999
+ extra = {}
1000
+ for k in ("capture", "boot_delay_ms", "periodically_ping_seconds",
1001
+ "periodically_ping_message"):
1002
+ if data.get(k) not in (None, ""):
1003
+ extra[k] = data[k]
1004
+ try:
1005
+ new_cfg = reconcile.add_agent(
1006
+ self.cfg,
1007
+ name,
1008
+ data.get("type") or "claude",
1009
+ command,
1010
+ can,
1011
+ role=data.get("role") or "",
1012
+ workdir=data.get("workdir"),
1013
+ **extra,
1014
+ )
1015
+ except Exception as exc:
1016
+ self._send_json(400, {"error": str(exc)})
1017
+ return
1018
+ UIHandler.cfg = new_cfg
1019
+ mail.init_mailboxes(new_cfg)
1020
+ self._send_json(200, {"ok": True, "name": name})
1021
+
1022
+ def _api_agent_edit(self, raw: bytes) -> None:
1023
+ data = self._json_body(raw)
1024
+ if data is None:
1025
+ return
1026
+ name = data.get("name")
1027
+ fields = data.get("fields")
1028
+ if not name or not isinstance(fields, dict) or not fields:
1029
+ self._send_json(400, {"error": "missing name/fields"})
1030
+ return
1031
+ # reconcile.edit_agent coerces each value from its str form; a list
1032
+ # can_talk_to must arrive as a comma string, or ``*`` for the wildcard.
1033
+ clean = {}
1034
+ for k, v in fields.items():
1035
+ clean[k] = ",".join(v) if k == "can_talk_to" and isinstance(v, list) else v
1036
+ try:
1037
+ new_cfg = reconcile.edit_agent(self.cfg, name, **clean)
1038
+ except Exception as exc:
1039
+ self._send_json(400, {"error": str(exc)})
1040
+ return
1041
+ UIHandler.cfg = new_cfg
1042
+ mail.init_mailboxes(new_cfg)
1043
+ self._send_json(200, {"ok": True, "name": name})
1044
+
1045
+ def _api_agent_remove(self, raw: bytes) -> None:
1046
+ data = self._json_body(raw)
1047
+ if data is None:
1048
+ return
1049
+ name = data.get("name")
1050
+ if not name:
1051
+ self._send_json(400, {"error": "missing name"})
1052
+ return
1053
+ if name not in self.cfg.names():
1054
+ self._send_json(404, {"error": "unknown agent"})
1055
+ return
1056
+ # Stop the session first so it isn't orphaned, then drop it from config.
1057
+ a = self.cfg.get(name)
1058
+ if tmux.session_exists(a.session):
1059
+ tmux.tmux("kill-session", "-t", f"={a.session}", check=False, capture=True)
1060
+ try:
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).
1064
+ new_cfg = reconcile.remove_agent(self.cfg, name)
1065
+ except Exception as exc:
1066
+ self._send_json(400, {"error": str(exc)})
1067
+ return
1068
+ UIHandler.cfg = new_cfg
1069
+ self._send_json(200, {"ok": True, "name": name})
1070
+
1071
+ # -- API: telegram bridge -------------------------------------------------
1072
+
1073
+ def _api_telegram(self) -> None:
1074
+ """Report the Telegram config (never the raw token) + poller state."""
1075
+ tg = self.cfg.telegram
1076
+ self._send_json(
1077
+ 200,
1078
+ {
1079
+ "enabled": bool(tg.enabled),
1080
+ "has_token": bool(tg.bot_token),
1081
+ "chat_id": tg.chat_id,
1082
+ "mirror": tg.mirror,
1083
+ "mirror_user": bool(tg.mirror_user),
1084
+ "mirror_system": bool(tg.mirror_system),
1085
+ "polling": _tg_poller is not None,
1086
+ "agents": self.cfg.names(),
1087
+ },
1088
+ )
1089
+
1090
+ def _api_telegram_post(self, raw: bytes) -> None:
1091
+ data = self._json_body(raw)
1092
+ if data is None:
1093
+ return
1094
+ fields = {}
1095
+ if "enabled" in data:
1096
+ fields["enabled"] = bool(data["enabled"])
1097
+ # Only overwrite the token when a fresh non-empty one is supplied, so the
1098
+ # editor can leave it blank to keep the stored secret.
1099
+ if data.get("bot_token"):
1100
+ fields["bot_token"] = str(data["bot_token"])
1101
+ if "chat_id" in data:
1102
+ fields["chat_id"] = str(data["chat_id"])
1103
+ if "mirror" in data:
1104
+ fields["mirror"] = data["mirror"]
1105
+ if "mirror_user" in data:
1106
+ fields["mirror_user"] = bool(data["mirror_user"])
1107
+ if "mirror_system" in data:
1108
+ fields["mirror_system"] = bool(data["mirror_system"])
1109
+ if not fields:
1110
+ self._send_json(400, {"error": "no telegram settings given"})
1111
+ return
1112
+ try:
1113
+ new_cfg = reconcile.edit_telegram(self.cfg, **fields)
1114
+ except Exception as exc:
1115
+ self._send_json(400, {"error": str(exc)})
1116
+ return
1117
+ UIHandler.cfg = new_cfg
1118
+ # If a reply poller is running, restart it so it picks up the new config.
1119
+ global _tg_poller
1120
+ with _tg_lock:
1121
+ if _tg_poller is not None:
1122
+ _tg_poller.stop()
1123
+ _tg_poller = telegram.start_poller(new_cfg)
1124
+ polling = _tg_poller is not None
1125
+ self._send_json(
1126
+ 200,
1127
+ {"ok": True, "enabled": bool(new_cfg.telegram.enabled),
1128
+ "has_token": bool(new_cfg.telegram.bot_token), "polling": polling},
1129
+ )
1130
+
1131
+ def _api_telegram_test(self, raw: bytes) -> None:
1132
+ data = self._json_body(raw)
1133
+ if data is None:
1134
+ return
1135
+ if not telegram.is_enabled(self.cfg):
1136
+ self._send_json(400, {"error": "telegram is not enabled / not fully configured"})
1137
+ return
1138
+ try:
1139
+ telegram.send_message(self.cfg, data.get("text") or "✅ Agentainer test message")
1140
+ except telegram.TelegramError as exc:
1141
+ self._send_json(400, {"error": str(exc)})
1142
+ return
1143
+ self._send_json(200, {"ok": True})
1144
+
1145
+ def _api_telegram_poll(self, raw: bytes) -> None:
1146
+ data = self._json_body(raw)
1147
+ if data is None:
1148
+ return
1149
+ global _tg_poller
1150
+ run = bool(data.get("run"))
1151
+ if run and not telegram.is_enabled(self.cfg):
1152
+ self._send_json(400, {"error": "telegram is not enabled / not fully configured"})
1153
+ return
1154
+ with _tg_lock:
1155
+ if run:
1156
+ if _tg_poller is None:
1157
+ _tg_poller = telegram.start_poller(self.cfg)
1158
+ elif _tg_poller is not None:
1159
+ _tg_poller.stop()
1160
+ _tg_poller = None
1161
+ polling = _tg_poller is not None
1162
+ self._send_json(200, {"ok": True, "polling": polling})
1163
+
1164
+
1165
+ def run_server(
1166
+ cfg,
1167
+ token: str,
1168
+ host: str = "127.0.0.1",
1169
+ port: int = 0,
1170
+ background: bool = True,
1171
+ ui_dir=None,
1172
+ ):
1173
+ """Bind and serve the Agentainer UI control plane.
1174
+
1175
+ Args:
1176
+ cfg: a loaded ``SwarmConfig`` (the source of truth for state).
1177
+ token: the auth token required for every API call / POST. May be empty
1178
+ ONLY when *host* is loopback (127.0.0.1 / localhost / ::1).
1179
+ host: bind interface. Defaults to ``127.0.0.1``. NEVER ``0.0.0.0``
1180
+ without a token -- a non-loopback bind with an empty token raises
1181
+ ``ValueError``.
1182
+ port: port to bind. ``0`` (default) lets the OS pick a free port; the
1183
+ real port is reported on the returned ``ServerHandle.port``.
1184
+ background: if True (default), serve in a daemon thread and return a
1185
+ ``ServerHandle`` immediately (call ``.shutdown()`` to stop). If
1186
+ False, block in ``serve_forever()`` and return the handle only
1187
+ after the server is stopped (use ``ui._last_server.shutdown()``
1188
+ from another thread, or the returned handle once it returns).
1189
+ ui_dir: override the static-asset directory (defaults to ``<repo>/ui``).
1190
+ Mainly for testing.
1191
+
1192
+ Returns:
1193
+ ServerHandle with ``.port`` (real bound port) and ``.shutdown()``.
1194
+ """
1195
+ if not _is_loopback(host) and not token:
1196
+ raise ValueError("a token is required to bind to a non-loopback host")
1197
+
1198
+ ui_path = Path(ui_dir) if ui_dir is not None else _DEFAULT_UI_DIR
1199
+ UIHandler.cfg = cfg
1200
+ UIHandler.token = token
1201
+ UIHandler.ui_dir = ui_path
1202
+
1203
+ server = ThreadingHTTPServer((host, port), UIHandler)
1204
+ real_port = server.server_address[1]
1205
+
1206
+ global _last_server
1207
+ _last_server = server
1208
+
1209
+ handle = ServerHandle(server, real_port, None)
1210
+ if background:
1211
+ thread = threading.Thread(target=server.serve_forever, daemon=True)
1212
+ thread.start()
1213
+ handle.thread = thread
1214
+ return handle
1215
+
1216
+ # Foreground: block until shutdown() is called from another thread, then
1217
+ # fall through and return the handle.
1218
+ server.serve_forever()
1219
+ return handle