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.
- package/README.md +248 -677
- package/agentainer +16 -18
- package/agentainer.example.yaml +86 -0
- package/bin/agentainer.js +9 -8
- package/examples/academic-coauthor.yaml +123 -0
- package/examples/accessibility-audit.yaml +152 -0
- package/examples/affiliate-product-reviews.yaml +106 -0
- package/examples/api-design.yaml +157 -0
- package/examples/app-store-optimization.yaml +108 -0
- package/examples/brainstorm.yaml +27 -128
- package/examples/brand-voice-style-guide.yaml +109 -0
- package/examples/bug-hunt.yaml +51 -96
- package/examples/candidate-screen.yaml +122 -0
- package/examples/case-study-writer.yaml +100 -0
- package/examples/changelog-release-notes.yaml +114 -0
- package/examples/chatbot-builder.yaml +138 -0
- package/examples/code-review.yaml +73 -0
- package/examples/comparison-guide-writer.yaml +106 -0
- package/examples/competitive-intel.yaml +126 -0
- package/examples/content-studio.yaml +91 -0
- package/examples/course-creator.yaml +133 -0
- package/examples/customer-support-triage.yaml +118 -0
- package/examples/daily-briefing.yaml +119 -0
- package/examples/data-pipeline-builder.yaml +135 -0
- package/examples/debate.yaml +16 -90
- package/examples/design-system.yaml +138 -0
- package/examples/ebook-generator.yaml +90 -0
- package/examples/ecommerce-listing-optimizer.yaml +126 -0
- package/examples/email-newsletter.yaml +103 -0
- package/examples/faq-knowledge-sync.yaml +107 -0
- package/examples/game-design.yaml +122 -0
- package/examples/glossary-term-writer.yaml +103 -0
- package/examples/incident-response.yaml +52 -109
- package/examples/knowledge-base.yaml +115 -0
- package/examples/landing-page-converter.yaml +103 -0
- package/examples/legal-contract-review.yaml +118 -0
- package/examples/linkedin-ghostwriter.yaml +93 -0
- package/examples/localization.yaml +56 -123
- package/examples/meeting-notes.yaml +111 -0
- package/examples/migration-planner.yaml +127 -0
- package/examples/onboarding-buddy.yaml +111 -0
- package/examples/performance-audit.yaml +123 -0
- package/examples/podcast-production.yaml +117 -0
- package/examples/postmortem.yaml +119 -0
- package/examples/pr-review-gate.yaml +123 -0
- package/examples/press-release-wire.yaml +96 -0
- package/examples/product-spec.yaml +107 -0
- package/examples/prompt-engineering-lab.yaml +109 -0
- package/examples/quickstart.yaml +48 -0
- package/examples/rag-builder.yaml +145 -0
- package/examples/refactor-planner.yaml +127 -0
- package/examples/research.yaml +25 -0
- package/examples/resume-tailor.yaml +116 -0
- package/examples/rfp-response.yaml +124 -0
- package/examples/sales-coach.yaml +123 -0
- package/examples/security-audit.yaml +120 -0
- package/examples/seo-audit-and-fix.yaml +138 -0
- package/examples/seo-content-factory.yaml +103 -0
- package/examples/social-media.yaml +103 -0
- package/examples/software-company.yaml +71 -128
- package/examples/startup-validator.yaml +115 -0
- package/examples/tdd-pingpong.yaml +36 -68
- package/examples/technical-documentation.yaml +112 -0
- package/examples/test-factory.yaml +114 -0
- package/examples/tutorial-howto-creator.yaml +111 -0
- package/examples/twitter-x-thread-factory.yaml +91 -0
- package/examples/white-paper-research.yaml +96 -0
- package/examples/writers-room.yaml +49 -111
- package/examples/youtube-script-studio.yaml +107 -0
- package/hooks/claude_stop.sh +5 -3
- package/hooks/codex_notify.sh +4 -3
- package/lib/cli.py +933 -0
- package/lib/config.py +267 -308
- package/lib/hooks.py +246 -0
- package/lib/lock.py +75 -0
- package/lib/log.py +64 -0
- package/lib/mail.py +699 -0
- package/lib/minyaml.py +1 -39
- package/lib/reconcile.py +544 -0
- package/lib/sessions.py +223 -0
- package/lib/supervisor.py +216 -0
- package/lib/telegram.py +372 -0
- package/lib/tmux.py +355 -0
- package/lib/turn.py +167 -0
- package/lib/ui.py +1219 -0
- package/llms.txt +145 -429
- package/package.json +9 -7
- package/scripts/check-deps.js +18 -61
- package/ui/app.js +1136 -0
- package/ui/index.html +404 -0
- package/agents.example.yaml +0 -257
- package/examples/code-review-broadcast.yaml +0 -109
- package/examples/existing-repo.yaml +0 -74
- package/examples/multi-language-broadcast.yaml +0 -127
- package/examples/ping-pong.yaml +0 -89
- package/examples/red-team.yaml +0 -117
- package/examples/research-swarm.yaml +0 -129
- 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
|