@matt82198/aesop 0.1.0-beta.2 → 0.1.0-beta.4
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/CHANGELOG.md +37 -94
- package/CLAUDE-TEMPLATE.md +115 -0
- package/README.md +89 -264
- package/aesop.config.example.json +27 -26
- package/bin/CLAUDE.md +39 -0
- package/bin/cli.js +368 -24
- package/daemons/CLAUDE.md +33 -0
- package/daemons/backup-fleet.sh +92 -21
- package/daemons/run-watchdog.sh +90 -10
- package/dash/CLAUDE.md +32 -0
- package/dash/dash-extra.mjs +194 -21
- package/dash/watchdog-gui.sh +23 -6
- package/docs/BEHAVIORAL-PR-REVIEW.md +86 -0
- package/docs/CHECKPOINTING.md +100 -0
- package/docs/DISPATCH-MODEL.md +11 -7
- package/docs/FORENSICS.md +204 -0
- package/docs/GOVERNANCE.md +4 -2
- package/docs/HOOK-INSTALL.md +307 -0
- package/docs/HOW-THE-LOOP-WORKS.md +175 -0
- package/docs/MEMORY-TEMPLATE.md +61 -0
- package/docs/README.md +44 -0
- package/docs/RELIABILITY.md +61 -0
- package/docs/RESTORE.md +397 -0
- package/docs/SCRIPTS-POLICY.md +97 -0
- package/docs/spikes/tiered-cognition/ACTIVATION.md +125 -0
- package/docs/spikes/tiered-cognition/DESIGN.md +287 -0
- package/docs/spikes/tiered-cognition/FINDINGS.md +113 -0
- package/docs/spikes/tiered-cognition/README.md +29 -0
- package/docs/spikes/tiered-cognition/aesop-cognition.example.md +32 -0
- package/docs/spikes/tiered-cognition/force-model-policy.merged.mjs +673 -0
- package/docs/spikes/tiered-cognition/strip-tools-hook.mjs +434 -0
- package/hooks/CLAUDE.md +38 -0
- package/hooks/claude/force-model-policy.mjs +159 -0
- package/hooks/pre-push-policy.sh +753 -0
- package/monitor/.signal-state.json +3 -0
- package/monitor/ACTIONS.log +1 -0
- package/monitor/BRIEF.md +24 -0
- package/monitor/CHARTER.md +29 -8
- package/monitor/CLAUDE.md +40 -0
- package/monitor/SIGNALS.json +54 -0
- package/monitor/collect-signals.mjs +529 -62
- package/package.json +12 -3
- package/skills/CLAUDE.md +3 -0
- package/skills/power/SKILL.md +161 -0
- package/tools/CLAUDE.md +191 -0
- package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
- package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
- package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
- package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
- package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
- package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
- package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
- package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
- package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
- package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
- package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
- package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
- package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
- package/tools/agent-forensics.sh +186 -0
- package/tools/buildlog.py +182 -0
- package/tools/ci_merge_wait.py +204 -0
- package/tools/ensure_state.py +94 -0
- package/tools/eod_sweep.py +239 -0
- package/tools/fleet_ledger.py +272 -0
- package/tools/heartbeat.py +131 -0
- package/tools/inbox_drain.py +249 -0
- package/tools/launch_tui.py +51 -3
- package/tools/lock.mjs +211 -0
- package/tools/metrics_gate.py +205 -0
- package/tools/orchestrator_status.py +99 -0
- package/tools/power_selftest.py +386 -0
- package/tools/prepublish_scan.py +84 -0
- package/tools/proposals.mjs +248 -0
- package/tools/reconstitute.sh +467 -0
- package/tools/rotate_logs.py +228 -0
- package/tools/scanner_selftest.py +303 -0
- package/tools/secret_scan.py +131 -31
- package/tools/stall_check.py +172 -0
- package/tools/verify_dash.py +694 -0
- package/tools/verify_submit_encoding.py +194 -0
- package/ui/CLAUDE.md +109 -0
- package/ui/__pycache__/agents.cpython-314.pyc +0 -0
- package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
- package/ui/__pycache__/config.cpython-314.pyc +0 -0
- package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
- package/ui/__pycache__/handler.cpython-314.pyc +0 -0
- package/ui/__pycache__/render.cpython-314.pyc +0 -0
- package/ui/__pycache__/serve.cpython-314.pyc +0 -0
- package/ui/__pycache__/sse.cpython-314.pyc +0 -0
- package/ui/agents.py +179 -0
- package/ui/api/__init__.py +84 -0
- package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
- package/ui/api/submit.py +58 -0
- package/ui/api/tracker.py +116 -0
- package/ui/collectors.py +586 -0
- package/ui/config.py +114 -0
- package/ui/csrf.py +140 -0
- package/ui/handler.py +426 -0
- package/ui/render.py +29 -0
- package/ui/serve.py +68 -796
- package/ui/sse.py +168 -0
- package/ui/templates/dashboard.html +1202 -0
package/ui/serve.py
CHANGED
|
@@ -1,806 +1,78 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""
|
|
3
|
-
Aesop Web Dashboard — stdlib-only local observability.
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
-
|
|
3
|
+
Aesop Web Dashboard — stdlib-only local observability (thin entry point).
|
|
4
|
+
|
|
5
|
+
Serves a dark-theme HTML dashboard on a configurable port (default 8770) with
|
|
6
|
+
realtime updates via GET /events (Server-Sent Events). No external dependencies.
|
|
7
|
+
|
|
8
|
+
Wave-9 split: this module is now a thin composition layer. The implementation
|
|
9
|
+
lives in focused siblings, each of which reads config.* at call time so config
|
|
10
|
+
values stay live across reloads:
|
|
11
|
+
- config.py — path / env / aesop.config.json resolution (+ reload())
|
|
12
|
+
- csrf.py — session-token generation + request validation
|
|
13
|
+
- collectors.py — read-only data collectors, tracker CRUD, SSE snapshots
|
|
14
|
+
- agents.py — agent transcript reading + path-safe id handling
|
|
15
|
+
- sse.py — SSE client registry, broadcast, background collector loop
|
|
16
|
+
- render.py — dashboard template rendering (templates/dashboard.html)
|
|
17
|
+
- handler.py — DashboardHandler (HTTP routing/endpoints) + run_server
|
|
18
|
+
|
|
19
|
+
serve.py re-exports the sibling symbols so `serve.X` keeps resolving for the
|
|
20
|
+
existing test suite (which loads this file by path and pokes its module globals)
|
|
21
|
+
and for `python ui/serve.py` as the unchanged entry point.
|
|
22
|
+
|
|
23
|
+
Configuration, CSRF, and SSE details are documented in ui/CLAUDE.md.
|
|
12
24
|
"""
|
|
13
|
-
import http.server
|
|
14
|
-
import json
|
|
15
25
|
import os
|
|
16
|
-
import re
|
|
17
|
-
import subprocess
|
|
18
26
|
import sys
|
|
19
|
-
import urllib.parse
|
|
20
|
-
from datetime import datetime
|
|
21
|
-
from pathlib import Path
|
|
22
|
-
from time import time
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
# ==============================================================================
|
|
26
|
-
# Configuration & Paths
|
|
27
|
-
# ==============================================================================
|
|
28
|
-
|
|
29
|
-
PORT = int(os.getenv("PORT", "8770"))
|
|
30
|
-
|
|
31
|
-
# Determine AESOP_ROOT
|
|
32
|
-
AESOP_ROOT = Path(os.getenv("AESOP_ROOT", Path.home() / "aesop"))
|
|
33
|
-
|
|
34
|
-
# Try to load config file for additional settings
|
|
35
|
-
CONFIG_FILE = AESOP_ROOT / "aesop.config.json"
|
|
36
|
-
config = {}
|
|
37
|
-
if CONFIG_FILE.exists():
|
|
38
|
-
try:
|
|
39
|
-
with open(CONFIG_FILE) as f:
|
|
40
|
-
config = json.load(f)
|
|
41
|
-
except:
|
|
42
|
-
pass
|
|
43
|
-
|
|
44
|
-
# Derive paths from config or defaults
|
|
45
|
-
STATE_DIR = Path(config.get("state_root", str(AESOP_ROOT / "state")))
|
|
46
|
-
SCAN_DIR = Path(config.get("scan_root", str(AESOP_ROOT / "scan")))
|
|
47
|
-
|
|
48
|
-
# Transcript path: configurable via env var or config
|
|
49
|
-
# Default placeholder: $BRAIN_ROOT/projects/<project-name> (user fills in)
|
|
50
|
-
TRANSCRIPTS_ROOT = Path(
|
|
51
|
-
os.getenv(
|
|
52
|
-
"AESOP_TRANSCRIPTS_ROOT",
|
|
53
|
-
config.get("transcripts_root", "~/.claude/projects")
|
|
54
|
-
)
|
|
55
|
-
).expanduser()
|
|
56
|
-
|
|
57
|
-
WATCHDOG_HEARTBEAT = STATE_DIR / ".watchdog-heartbeat"
|
|
58
|
-
MONITOR_HEARTBEAT = STATE_DIR / ".monitor-heartbeat"
|
|
59
|
-
REPOS_JSON = STATE_DIR / ".watchdog-repos.json"
|
|
60
|
-
BACKUP_LOG = STATE_DIR / "FLEET-BACKUP.log"
|
|
61
|
-
ALERTS_LOG = SCAN_DIR / "SECURITY-ALERTS.log"
|
|
62
|
-
INBOX_FILE = STATE_DIR / "ui-inbox.md"
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
# ==============================================================================
|
|
66
|
-
# Data Collection Functions
|
|
67
|
-
# ==============================================================================
|
|
68
|
-
|
|
69
|
-
def get_heartbeat_status():
|
|
70
|
-
"""Read daemon heartbeat age and status."""
|
|
71
|
-
try:
|
|
72
|
-
if not WATCHDOG_HEARTBEAT.exists():
|
|
73
|
-
return {"alive": "UNKNOWN", "age": -1, "threshold": 300}
|
|
74
|
-
content = WATCHDOG_HEARTBEAT.read_text().strip()
|
|
75
|
-
if not content:
|
|
76
|
-
return {"alive": "UNKNOWN", "age": -1, "threshold": 300}
|
|
77
|
-
# Parse epoch value robustly; assume seconds (standard epoch format)
|
|
78
|
-
try:
|
|
79
|
-
timestamp = int(content)
|
|
80
|
-
except ValueError:
|
|
81
|
-
# Retry once in case of race during daemon write
|
|
82
|
-
try:
|
|
83
|
-
content = WATCHDOG_HEARTBEAT.read_text().strip()
|
|
84
|
-
timestamp = int(content)
|
|
85
|
-
except:
|
|
86
|
-
return {"alive": "unknown", "age": -1, "threshold": 300}
|
|
87
|
-
# Age in seconds: now_seconds - heartbeat_seconds
|
|
88
|
-
age_seconds = int(time()) - timestamp
|
|
89
|
-
alive = "ALIVE" if age_seconds < 300 else "STALE"
|
|
90
|
-
return {"alive": alive, "age": age_seconds, "threshold": 300}
|
|
91
|
-
except:
|
|
92
|
-
return {"alive": "unknown", "age": -1, "threshold": 300}
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
def get_monitor_heartbeat_status():
|
|
96
|
-
"""Read orchestration monitor heartbeat age and status."""
|
|
97
|
-
try:
|
|
98
|
-
# Check both possible paths: state/.monitor-heartbeat and monitor/.monitor-heartbeat
|
|
99
|
-
monitor_hb = MONITOR_HEARTBEAT
|
|
100
|
-
if not monitor_hb.exists():
|
|
101
|
-
# Try alternate path
|
|
102
|
-
alt_path = AESOP_ROOT / "monitor" / ".monitor-heartbeat"
|
|
103
|
-
if not alt_path.exists():
|
|
104
|
-
return {"alive": "not running", "age": -1, "threshold": 3600}
|
|
105
|
-
monitor_hb = alt_path
|
|
106
|
-
|
|
107
|
-
content = monitor_hb.read_text().strip()
|
|
108
|
-
if not content:
|
|
109
|
-
return {"alive": "not running", "age": -1, "threshold": 3600}
|
|
110
|
-
# Parse epoch value robustly; assume seconds (standard epoch format)
|
|
111
|
-
try:
|
|
112
|
-
timestamp = int(content)
|
|
113
|
-
except ValueError:
|
|
114
|
-
# Retry once in case of race during monitor write
|
|
115
|
-
try:
|
|
116
|
-
content = monitor_hb.read_text().strip()
|
|
117
|
-
timestamp = int(content)
|
|
118
|
-
except:
|
|
119
|
-
return {"alive": "unknown", "age": -1, "threshold": 3600}
|
|
120
|
-
# Age in seconds: now_seconds - heartbeat_seconds
|
|
121
|
-
age_seconds = int(time()) - timestamp
|
|
122
|
-
alive = "ALIVE" if age_seconds < 3600 else "STALE"
|
|
123
|
-
return {"alive": alive, "age": age_seconds, "threshold": 3600}
|
|
124
|
-
except:
|
|
125
|
-
return {"alive": "unknown", "age": -1, "threshold": 3600}
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
def get_fleet_agents():
|
|
129
|
-
"""Detect running subagents by calling dash-extra.mjs --json."""
|
|
130
|
-
agents = []
|
|
131
|
-
try:
|
|
132
|
-
# Call the working detector (dash-extra.mjs) with --json flag
|
|
133
|
-
dash_extra_path = AESOP_ROOT / "dash" / "dash-extra.mjs"
|
|
134
|
-
if not dash_extra_path.exists():
|
|
135
|
-
return agents
|
|
136
|
-
result = subprocess.run(
|
|
137
|
-
["node", str(dash_extra_path), "--json"],
|
|
138
|
-
capture_output=True,
|
|
139
|
-
text=True,
|
|
140
|
-
timeout=5
|
|
141
|
-
)
|
|
142
|
-
if result.returncode == 0 and result.stdout:
|
|
143
|
-
agents = json.loads(result.stdout.strip())
|
|
144
|
-
except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError):
|
|
145
|
-
pass
|
|
146
|
-
except Exception:
|
|
147
|
-
pass
|
|
148
|
-
return agents
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
def get_main_thread_messages():
|
|
152
|
-
"""Read last ~12 messages from newest session JSONL."""
|
|
153
|
-
messages = []
|
|
154
|
-
try:
|
|
155
|
-
if not TRANSCRIPTS_ROOT.exists():
|
|
156
|
-
return messages
|
|
157
|
-
# Find newest .jsonl
|
|
158
|
-
jsonl_files = sorted(
|
|
159
|
-
TRANSCRIPTS_ROOT.glob("**/*.jsonl"),
|
|
160
|
-
key=lambda p: p.stat().st_mtime,
|
|
161
|
-
reverse=True
|
|
162
|
-
)
|
|
163
|
-
if not jsonl_files:
|
|
164
|
-
return messages
|
|
165
|
-
|
|
166
|
-
newest = jsonl_files[0]
|
|
167
|
-
with open(newest, 'r', encoding='utf-8', errors='ignore') as f:
|
|
168
|
-
lines = f.readlines()
|
|
169
|
-
# Get last 30 lines to extract ~12 message turns
|
|
170
|
-
for line in lines[-30:]:
|
|
171
|
-
try:
|
|
172
|
-
obj = json.loads(line)
|
|
173
|
-
role = obj.get("role", "unknown")
|
|
174
|
-
if role in ("user", "assistant"):
|
|
175
|
-
# Extract text content
|
|
176
|
-
content = obj.get("content", [])
|
|
177
|
-
text = ""
|
|
178
|
-
if isinstance(content, list):
|
|
179
|
-
for block in content:
|
|
180
|
-
if isinstance(block, dict) and "text" in block:
|
|
181
|
-
text = block["text"]
|
|
182
|
-
break
|
|
183
|
-
elif isinstance(content, str):
|
|
184
|
-
text = content
|
|
185
|
-
|
|
186
|
-
if text:
|
|
187
|
-
# Truncate to 200 chars and sanitize
|
|
188
|
-
preview = text[:200].replace("\n", " ").strip()
|
|
189
|
-
timestamp = obj.get("timestamp", "")
|
|
190
|
-
messages.append({
|
|
191
|
-
"role": role,
|
|
192
|
-
"text": preview,
|
|
193
|
-
"timestamp": timestamp
|
|
194
|
-
})
|
|
195
|
-
except (json.JSONDecodeError, KeyError):
|
|
196
|
-
pass
|
|
197
|
-
# Keep only last 12
|
|
198
|
-
messages = messages[-12:]
|
|
199
|
-
except:
|
|
200
|
-
pass
|
|
201
|
-
return messages
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
def get_repos_status():
|
|
205
|
-
"""Read repos from .watchdog-repos.json."""
|
|
206
|
-
repos = []
|
|
207
|
-
try:
|
|
208
|
-
if not REPOS_JSON.exists():
|
|
209
|
-
return repos
|
|
210
|
-
data = json.loads(REPOS_JSON.read_text())
|
|
211
|
-
if isinstance(data, list):
|
|
212
|
-
repos = data[:10] # Limit to 10
|
|
213
|
-
elif isinstance(data, dict):
|
|
214
|
-
repos = [{"repo": k, "state": v} for k, v in data.items()][:10]
|
|
215
|
-
except:
|
|
216
|
-
pass
|
|
217
|
-
return repos
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
def get_recent_events():
|
|
221
|
-
"""Read last 8 lines from FLEET-BACKUP.log."""
|
|
222
|
-
events = []
|
|
223
|
-
try:
|
|
224
|
-
if not BACKUP_LOG.exists():
|
|
225
|
-
return events
|
|
226
|
-
lines = BACKUP_LOG.read_text().strip().split('\n')
|
|
227
|
-
events = [line.strip() for line in lines[-8:] if line.strip()]
|
|
228
|
-
except:
|
|
229
|
-
pass
|
|
230
|
-
return events
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
def get_alerts():
|
|
234
|
-
"""Read SECURITY-ALERTS.log, skip NOTE:/RESOLVED-FP, count by severity."""
|
|
235
|
-
alerts = {"count": 0, "lines": []}
|
|
236
|
-
try:
|
|
237
|
-
if not ALERTS_LOG.exists():
|
|
238
|
-
return alerts
|
|
239
|
-
lines = ALERTS_LOG.read_text().strip().split('\n')
|
|
240
|
-
unreviewed = [
|
|
241
|
-
line.strip() for line in lines
|
|
242
|
-
if line.strip()
|
|
243
|
-
and "NOTE:" not in line
|
|
244
|
-
and "RESOLVED-FP" not in line
|
|
245
|
-
]
|
|
246
|
-
alerts["count"] = len(unreviewed)
|
|
247
|
-
alerts["lines"] = unreviewed[-5:] # Show last 5
|
|
248
|
-
except:
|
|
249
|
-
pass
|
|
250
|
-
return alerts
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
def extract_agent_dispatch_prompt(agent_id):
|
|
254
|
-
"""
|
|
255
|
-
Extract dispatch prompt and metadata from agent output file.
|
|
256
|
-
Returns dict with prompt, dispatcher, model, activity times, and message count.
|
|
257
|
-
Robust: missing/invalid file -> {error: "..."}
|
|
258
|
-
|
|
259
|
-
CRITICAL: Use prefix-matching via glob, not exact match. The dashboard supplies
|
|
260
|
-
truncated agent IDs; files on disk carry full IDs (e.g., a77b995bcdb953e9c.output).
|
|
261
|
-
"""
|
|
262
|
-
try:
|
|
263
|
-
# Prefix-match: search in TRANSCRIPTS_ROOT for files matching agent_id*.output
|
|
264
|
-
if not TRANSCRIPTS_ROOT.exists():
|
|
265
|
-
return {"error": f"transcripts root not found at {TRANSCRIPTS_ROOT}"}
|
|
266
|
-
|
|
267
|
-
# Glob for matching files (prefix-match handles truncated IDs)
|
|
268
|
-
matches = sorted(
|
|
269
|
-
TRANSCRIPTS_ROOT.glob(f"**/{agent_id}*.output"),
|
|
270
|
-
key=lambda p: p.stat().st_mtime, reverse=True,
|
|
271
|
-
)
|
|
272
|
-
if not matches:
|
|
273
|
-
return {"error": f"transcript not found for {agent_id}"}
|
|
274
|
-
output_file = matches[0]
|
|
275
|
-
|
|
276
|
-
dispatch_prompt = None
|
|
277
|
-
message_count = 0
|
|
278
|
-
model = None
|
|
279
|
-
parent_uuid = None
|
|
280
|
-
first_seen = None
|
|
281
|
-
last_activity = None
|
|
282
|
-
|
|
283
|
-
# Parse NDJSON (one JSON per line)
|
|
284
|
-
with open(output_file, 'r', encoding='utf-8', errors='ignore') as f:
|
|
285
|
-
lines = f.readlines()
|
|
286
|
-
message_count = len(lines)
|
|
287
|
-
|
|
288
|
-
# Get file mtime for activity time
|
|
289
|
-
stat = output_file.stat()
|
|
290
|
-
first_seen = int(stat.st_mtime)
|
|
291
|
-
last_activity = int(stat.st_mtime)
|
|
292
|
-
|
|
293
|
-
# First line should be type="user" with the dispatch prompt
|
|
294
|
-
if lines:
|
|
295
|
-
try:
|
|
296
|
-
first_line = json.loads(lines[0])
|
|
297
|
-
if first_line.get('type') == 'user':
|
|
298
|
-
msg = first_line.get('message', {})
|
|
299
|
-
dispatch_prompt = msg.get('content', '')
|
|
300
|
-
parent_uuid = first_line.get('parentUuid')
|
|
301
|
-
except (json.JSONDecodeError, KeyError):
|
|
302
|
-
pass
|
|
303
|
-
|
|
304
|
-
# Scan for model info in assistant messages
|
|
305
|
-
for line in lines[1:20]: # Check first ~20 lines
|
|
306
|
-
try:
|
|
307
|
-
obj = json.loads(line)
|
|
308
|
-
if obj.get('type') == 'assistant' and not model:
|
|
309
|
-
if 'model' in obj:
|
|
310
|
-
model = obj.get('model')
|
|
311
|
-
except (json.JSONDecodeError, KeyError):
|
|
312
|
-
pass
|
|
313
|
-
|
|
314
|
-
if not dispatch_prompt:
|
|
315
|
-
return {"error": f"no dispatch prompt found"}
|
|
316
|
-
|
|
317
|
-
# Infer dispatcher: if parentUuid is null, it's main thread; otherwise parent agent
|
|
318
|
-
dispatcher = "main thread" if parent_uuid is None else "parent agent"
|
|
319
|
-
|
|
320
|
-
return {
|
|
321
|
-
"id": agent_id,
|
|
322
|
-
"dispatch_prompt": dispatch_prompt,
|
|
323
|
-
"dispatcher": dispatcher,
|
|
324
|
-
"model": model or "unknown",
|
|
325
|
-
"message_count": message_count,
|
|
326
|
-
"first_seen": first_seen,
|
|
327
|
-
"last_activity": last_activity,
|
|
328
|
-
}
|
|
329
|
-
except Exception as e:
|
|
330
|
-
return {"error": str(e)}
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
# ==============================================================================
|
|
334
|
-
# HTTP Server
|
|
335
|
-
# ==============================================================================
|
|
336
|
-
|
|
337
|
-
class DashboardHandler(http.server.BaseHTTPRequestHandler):
|
|
338
|
-
"""HTTP request handler for dashboard."""
|
|
339
|
-
|
|
340
|
-
def log_message(self, format, *args):
|
|
341
|
-
"""Suppress default logging."""
|
|
342
|
-
pass
|
|
343
|
-
|
|
344
|
-
def do_GET(self):
|
|
345
|
-
"""Handle GET requests."""
|
|
346
|
-
if self.path == "/":
|
|
347
|
-
self.serve_html()
|
|
348
|
-
elif self.path == "/data":
|
|
349
|
-
self.serve_data()
|
|
350
|
-
elif self.path.startswith("/agent?"):
|
|
351
|
-
self.serve_agent()
|
|
352
|
-
else:
|
|
353
|
-
self.send_error(404)
|
|
354
|
-
|
|
355
|
-
def do_POST(self):
|
|
356
|
-
"""Handle POST requests."""
|
|
357
|
-
if self.path == "/submit":
|
|
358
|
-
self.handle_submit()
|
|
359
|
-
else:
|
|
360
|
-
self.send_error(404)
|
|
361
|
-
|
|
362
|
-
def serve_html(self):
|
|
363
|
-
"""Serve the dashboard HTML."""
|
|
364
|
-
html = """<!DOCTYPE html>
|
|
365
|
-
<html lang="en">
|
|
366
|
-
<head>
|
|
367
|
-
<meta charset="UTF-8">
|
|
368
|
-
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
369
|
-
<title>Aesop Fleet Dashboard</title>
|
|
370
|
-
<style>
|
|
371
|
-
* { margin: 0; padding: 0; box-sizing: border-box; }
|
|
372
|
-
html { color-scheme: dark; }
|
|
373
|
-
body {
|
|
374
|
-
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Monospace;
|
|
375
|
-
background: #0a0a0a;
|
|
376
|
-
color: #e0e0e0;
|
|
377
|
-
padding: 16px;
|
|
378
|
-
line-height: 1.5;
|
|
379
|
-
}
|
|
380
|
-
.container { max-width: 1600px; margin: 0 auto; }
|
|
381
|
-
h1 { font-size: 20px; margin-bottom: 20px; color: #fff; }
|
|
382
|
-
h2 { font-size: 14px; margin-top: 20px; margin-bottom: 10px; color: #8ac; font-weight: bold; }
|
|
383
|
-
|
|
384
|
-
.header { display: flex; gap: 20px; margin-bottom: 20px; padding: 12px; background: #1a1a1a; border-radius: 4px; }
|
|
385
|
-
.header-item { flex: 1; }
|
|
386
|
-
.header-label { font-size: 11px; color: #666; text-transform: uppercase; margin-bottom: 4px; }
|
|
387
|
-
.header-value { font-size: 14px; color: #fff; font-weight: bold; }
|
|
388
|
-
.status-alive { color: #0a0; }
|
|
389
|
-
.status-stale { color: #f44; }
|
|
390
|
-
.status-unknown { color: #999; }
|
|
391
|
-
|
|
392
|
-
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-bottom: 20px; }
|
|
393
|
-
@media (max-width: 1200px) { .grid { grid-template-columns: 1fr; } }
|
|
394
|
-
|
|
395
|
-
.panel { background: #1a1a1a; border: 1px solid #333; border-radius: 4px; padding: 12px; }
|
|
396
|
-
.panel-title { font-size: 12px; color: #8ac; font-weight: bold; text-transform: uppercase; margin-bottom: 8px; display: flex; align-items: center; gap: 6px; }
|
|
397
|
-
.panel-title-emoji { font-size: 14px; }
|
|
398
|
-
|
|
399
|
-
/* Agent row expand/collapse */
|
|
400
|
-
.agent-row { cursor: pointer; padding: 8px; margin-bottom: 4px; background: #0f0f0f; border: 1px solid #2a2a2a; border-radius: 3px; transition: all 0.2s ease; display: flex; align-items: center; gap: 8px; }
|
|
401
|
-
.agent-row:hover { background: #151515; border-color: #444; }
|
|
402
|
-
.agent-row.expanded { background: #1a1a1a; border-color: #8ac; }
|
|
403
|
-
.agent-status-icon { font-size: 12px; width: 14px; }
|
|
404
|
-
.agent-row-header { flex: 1; font-size: 12px; display: flex; gap: 12px; align-items: center; }
|
|
405
|
-
.agent-id-badge { color: #8ac; font-weight: bold; }
|
|
406
|
-
.agent-age { color: #666; font-size: 11px; }
|
|
407
|
-
.agent-preview { color: #999; font-size: 11px; flex: 1; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
|
408
|
-
.agent-expand-toggle { color: #666; font-size: 11px; transition: transform 0.2s ease; }
|
|
409
|
-
.agent-row.expanded .agent-expand-toggle { transform: rotate(90deg); }
|
|
410
|
-
|
|
411
|
-
.agent-details { display: none; margin-top: 8px; padding: 12px; background: #0f0f0f; border-left: 3px solid #8ac; border-radius: 2px; max-height: 0; overflow: hidden; transition: max-height 0.3s ease; }
|
|
412
|
-
.agent-row.expanded .agent-details { display: block; max-height: 600px; }
|
|
413
|
-
.detail-row { margin-bottom: 8px; font-size: 11px; }
|
|
414
|
-
.detail-label { color: #8ac; font-weight: bold; display: inline-block; width: 100px; }
|
|
415
|
-
.detail-value { color: #ccc; word-break: break-all; }
|
|
416
|
-
.dispatch-prompt { background: #0a0a0a; border: 1px solid #333; border-radius: 2px; padding: 8px; margin-top: 6px; max-height: 300px; overflow-y: auto; font-size: 11px; color: #ccc; font-family: 'Monaco', 'Menlo', monospace; white-space: pre-wrap; word-wrap: break-word; }
|
|
417
|
-
|
|
418
|
-
.item { padding: 8px 0; font-size: 12px; border-bottom: 1px solid #2a2a2a; }
|
|
419
|
-
.item:last-child { border-bottom: none; }
|
|
420
|
-
.item-id { color: #8ac; font-weight: bold; }
|
|
421
|
-
.item-age { color: #999; margin: 0 8px; }
|
|
422
|
-
.item-status { padding: 2px 6px; border-radius: 2px; font-size: 10px; font-weight: bold; }
|
|
423
|
-
.status-running { background: #0a0; color: #000; }
|
|
424
|
-
.status-done { background: #666; color: #fff; }
|
|
425
|
-
|
|
426
|
-
.inbox-box { background: #1a1a1a; border: 1px solid #333; border-radius: 4px; padding: 12px; margin-bottom: 20px; }
|
|
427
|
-
.inbox-label { font-size: 11px; color: #666; text-transform: uppercase; margin-bottom: 8px; }
|
|
428
|
-
.inbox-input { width: 100%; padding: 8px; background: #0a0a0a; border: 1px solid #333; color: #e0e0e0; border-radius: 2px; font-size: 12px; font-family: inherit; }
|
|
429
|
-
.inbox-input:focus { outline: none; border-color: #8ac; }
|
|
430
|
-
.inbox-button { background: #8ac; color: #000; border: none; padding: 8px 16px; border-radius: 2px; margin-top: 8px; cursor: pointer; font-weight: bold; font-size: 12px; }
|
|
431
|
-
.inbox-button:hover { background: #9bd; }
|
|
432
|
-
.inbox-button:disabled { background: #555; cursor: not-allowed; }
|
|
433
|
-
.inbox-status { font-size: 11px; color: #0a0; margin-top: 4px; display: none; }
|
|
434
|
-
|
|
435
|
-
.alerts-box { background: #1a1a1a; border: 1px solid #333; border-radius: 4px; padding: 12px; }
|
|
436
|
-
.alert-line { font-size: 11px; padding: 4px 0; color: #f44; font-family: monospace; }
|
|
437
|
-
.alert-none { color: #666; }
|
|
438
|
-
|
|
439
|
-
.messages-box { background: #1a1a1a; border: 1px solid #333; border-radius: 4px; padding: 12px; max-height: 400px; overflow-y: auto; }
|
|
440
|
-
.message { padding: 8px 0; border-bottom: 1px solid #2a2a2a; font-size: 11px; }
|
|
441
|
-
.message:last-child { border-bottom: none; }
|
|
442
|
-
.message-role { color: #8ac; font-weight: bold; }
|
|
443
|
-
.message-time { color: #666; font-size: 10px; margin-left: 8px; }
|
|
444
|
-
.message-text { color: #ccc; margin-top: 4px; }
|
|
445
|
-
|
|
446
|
-
.loading { color: #666; font-style: italic; }
|
|
447
|
-
.error { color: #f44; }
|
|
448
|
-
.fade-in { animation: fadeIn 0.3s ease-in; }
|
|
449
|
-
@keyframes fadeIn { from { opacity: 0.5; } to { opacity: 1; } }
|
|
450
|
-
</style>
|
|
451
|
-
</head>
|
|
452
|
-
<body>
|
|
453
|
-
<div class="container">
|
|
454
|
-
<h1>Aesop Fleet Dashboard</h1>
|
|
455
|
-
|
|
456
|
-
<div class="header" id="header">
|
|
457
|
-
<div class="header-item">
|
|
458
|
-
<div class="header-label">Watchdog Status</div>
|
|
459
|
-
<div class="header-value" id="watchdog-status">
|
|
460
|
-
<span id="watchdog-alive" class="status-unknown">—</span>
|
|
461
|
-
<span id="watchdog-age" style="color: #999; margin-left: 8px;">—</span>
|
|
462
|
-
</div>
|
|
463
|
-
</div>
|
|
464
|
-
<div class="header-item">
|
|
465
|
-
<div class="header-label">Monitor Status</div>
|
|
466
|
-
<div class="header-value" id="monitor-status">
|
|
467
|
-
<span id="monitor-alive" class="status-unknown">—</span>
|
|
468
|
-
<span id="monitor-age" style="color: #999; margin-left: 8px;">—</span>
|
|
469
|
-
</div>
|
|
470
|
-
</div>
|
|
471
|
-
<div class="header-item">
|
|
472
|
-
<div class="header-label">Security Alerts</div>
|
|
473
|
-
<div class="header-value" id="alert-count" style="color: #999;">—</div>
|
|
474
|
-
</div>
|
|
475
|
-
<div class="header-item">
|
|
476
|
-
<div class="header-label">Running Agents</div>
|
|
477
|
-
<div class="header-value" id="running-count" style="color: #999;">—</div>
|
|
478
|
-
</div>
|
|
479
|
-
</div>
|
|
480
|
-
|
|
481
|
-
<div class="inbox-box">
|
|
482
|
-
<div class="inbox-label">Queue Work (Read by Orchestrator Each Turn)</div>
|
|
483
|
-
<input type="text" class="inbox-input" id="inbox-input" placeholder="Type your task here...">
|
|
484
|
-
<button class="inbox-button" id="inbox-button">Send to Inbox</button>
|
|
485
|
-
<div class="inbox-status" id="inbox-status">Queued ✓</div>
|
|
486
|
-
</div>
|
|
487
|
-
|
|
488
|
-
<div class="grid">
|
|
489
|
-
<div class="panel">
|
|
490
|
-
<div class="panel-title">
|
|
491
|
-
<span class="panel-title-emoji">⚡</span>
|
|
492
|
-
<span>Fleet Agents (<span id="running-agents-count">0</span> active)</span>
|
|
493
|
-
</div>
|
|
494
|
-
<div id="agents-list" class="loading">—</div>
|
|
495
|
-
</div>
|
|
496
|
-
|
|
497
|
-
<div class="panel">
|
|
498
|
-
<div class="panel-title">Repos Status</div>
|
|
499
|
-
<div id="repos-list" class="loading">—</div>
|
|
500
|
-
</div>
|
|
501
|
-
</div>
|
|
502
|
-
|
|
503
|
-
<div class="grid">
|
|
504
|
-
<div class="panel">
|
|
505
|
-
<div class="panel-title">Recent Events (Last 8)</div>
|
|
506
|
-
<div id="events-list" class="loading">—</div>
|
|
507
|
-
</div>
|
|
508
|
-
|
|
509
|
-
<div class="alerts-box">
|
|
510
|
-
<div class="panel-title">Security Alerts (Unreviewed)</div>
|
|
511
|
-
<div id="alerts-list" class="alert-none">—</div>
|
|
512
|
-
</div>
|
|
513
|
-
</div>
|
|
514
|
-
|
|
515
|
-
<div class="panel">
|
|
516
|
-
<div class="panel-title">Main-Thread Prompts (Last ~12 Messages)</div>
|
|
517
|
-
<div class="messages-box" id="messages-list" class="loading">—</div>
|
|
518
|
-
</div>
|
|
519
|
-
|
|
520
|
-
<div style="text-align: center; margin-top: 30px; color: #666; font-size: 11px;">
|
|
521
|
-
Auto-refresh every 3s · Dashboard · Click agent rows to inspect dispatches
|
|
522
|
-
</div>
|
|
523
|
-
</div>
|
|
524
|
-
|
|
525
|
-
<script>
|
|
526
|
-
let lastRefresh = 0;
|
|
527
|
-
|
|
528
|
-
function formatTimestamp(iso) {
|
|
529
|
-
if (!iso) return '';
|
|
530
|
-
const d = new Date(iso);
|
|
531
|
-
return d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
532
|
-
}
|
|
533
|
-
|
|
534
|
-
function sanitize(text) {
|
|
535
|
-
const div = document.createElement('div');
|
|
536
|
-
div.textContent = text;
|
|
537
|
-
return div.innerHTML;
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
async function refresh() {
|
|
541
|
-
try {
|
|
542
|
-
const response = await fetch('/data');
|
|
543
|
-
if (!response.ok) return;
|
|
544
|
-
const data = await response.json();
|
|
545
|
-
|
|
546
|
-
// Update header
|
|
547
|
-
const watchdog = data.watchdog || {};
|
|
548
|
-
const watchdogAlive = document.getElementById('watchdog-alive');
|
|
549
|
-
watchdogAlive.textContent = watchdog.alive || '—';
|
|
550
|
-
watchdogAlive.className = 'status-' + (watchdog.alive || 'unknown').toLowerCase();
|
|
551
|
-
document.getElementById('watchdog-age').textContent = watchdog.age >= 0 ? watchdog.age + 's' : '—';
|
|
552
|
-
|
|
553
|
-
const monitor = data.monitor || {};
|
|
554
|
-
const monitorAlive = document.getElementById('monitor-alive');
|
|
555
|
-
monitorAlive.textContent = monitor.alive || '—';
|
|
556
|
-
monitorAlive.className = 'status-' + (monitor.alive || 'unknown').toLowerCase();
|
|
557
|
-
document.getElementById('monitor-age').textContent = monitor.age >= 0 ? monitor.age + 's' : '—';
|
|
558
|
-
|
|
559
|
-
document.getElementById('alert-count').textContent = data.alerts?.count || 0;
|
|
560
|
-
const runningAgents = (data.agents || []).filter(a => a.status === 'running').length;
|
|
561
|
-
document.getElementById('running-count').textContent = runningAgents;
|
|
562
|
-
|
|
563
|
-
// Agents with expandable details
|
|
564
|
-
const agentsList = document.getElementById('agents-list');
|
|
565
|
-
document.getElementById('running-agents-count').textContent = (data.agents || []).length;
|
|
566
|
-
if (data.agents && data.agents.length > 0) {
|
|
567
|
-
agentsList.innerHTML = data.agents.map(a => {
|
|
568
|
-
const statusEmoji = a.status === 'running' ? '🟢' : a.status === 'done' ? '⚪' : '⚠️';
|
|
569
|
-
const preview = (a.hint || '').substring(0, 60).replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
|
|
570
|
-
return `<div class="agent-row fade-in" data-agent-id="${sanitize(a.id)}">
|
|
571
|
-
<span class="agent-status-icon">${statusEmoji}</span>
|
|
572
|
-
<div class="agent-row-header">
|
|
573
|
-
<span class="agent-id-badge">${sanitize(a.id)}</span>
|
|
574
|
-
<span class="agent-age">${a.age_s}s</span>
|
|
575
|
-
<span class="agent-preview">${preview}</span>
|
|
576
|
-
</div>
|
|
577
|
-
<span class="agent-expand-toggle">▶</span>
|
|
578
|
-
<div class="agent-details" style="display: none;"></div>
|
|
579
|
-
</div>`;
|
|
580
|
-
}).join('');
|
|
581
|
-
// Attach click handlers for expansion
|
|
582
|
-
document.querySelectorAll('.agent-row').forEach(row => {
|
|
583
|
-
row.addEventListener('click', async function(e) {
|
|
584
|
-
e.stopPropagation();
|
|
585
|
-
const agentId = this.dataset.agentId;
|
|
586
|
-
this.classList.toggle('expanded');
|
|
587
|
-
const detailsDiv = this.querySelector('.agent-details');
|
|
588
|
-
if (this.classList.contains('expanded') && !detailsDiv.innerHTML) {
|
|
589
|
-
// Fetch details on first expand
|
|
590
|
-
try {
|
|
591
|
-
const resp = await fetch('/agent?id=' + encodeURIComponent(agentId));
|
|
592
|
-
const details = await resp.json();
|
|
593
|
-
if (details.error) {
|
|
594
|
-
detailsDiv.innerHTML = `<div class="detail-row"><span class="detail-label">Error:</span> <span class="detail-value">${sanitize(details.error)}</span></div>`;
|
|
595
|
-
} else {
|
|
596
|
-
const uptime = Math.floor((Date.now() / 1000 - details.last_activity) / 60);
|
|
597
|
-
detailsDiv.innerHTML = `
|
|
598
|
-
<div class="detail-row"><span class="detail-label">Dispatcher:</span> <span class="detail-value">${sanitize(details.dispatcher)}</span></div>
|
|
599
|
-
<div class="detail-row"><span class="detail-label">Model:</span> <span class="detail-value">${sanitize(details.model)}</span></div>
|
|
600
|
-
<div class="detail-row"><span class="detail-label">Messages:</span> <span class="detail-value">${details.message_count}</span></div>
|
|
601
|
-
<div class="detail-row"><span class="detail-label">Prompt:</span></div>
|
|
602
|
-
<div class="dispatch-prompt">${sanitize(details.dispatch_prompt)}</div>
|
|
603
|
-
`;
|
|
604
|
-
}
|
|
605
|
-
} catch (e) {
|
|
606
|
-
detailsDiv.innerHTML = `<div class="detail-row" style="color: #f44;">Failed to fetch details: ${sanitize(e.message)}</div>`;
|
|
607
|
-
}
|
|
608
|
-
}
|
|
609
|
-
});
|
|
610
|
-
});
|
|
611
|
-
} else {
|
|
612
|
-
agentsList.innerHTML = '<div style="color: #666; font-size: 12px;">💤 No active agents — fleet is idle</div>';
|
|
613
|
-
}
|
|
614
|
-
|
|
615
|
-
// Repos
|
|
616
|
-
const reposList = document.getElementById('repos-list');
|
|
617
|
-
if (data.repos && data.repos.length > 0) {
|
|
618
|
-
reposList.innerHTML = data.repos.map(r => {
|
|
619
|
-
const repo = r.repo || Object.keys(r)[0] || 'unknown';
|
|
620
|
-
const state = r.state || r[repo] || 'unknown';
|
|
621
|
-
return `<div class="item"><span class="item-id">${sanitize(repo.substring(0, 30))}</span> <span style="color: #999;">${sanitize(state)}</span></div>`;
|
|
622
|
-
}).join('');
|
|
623
|
-
} else {
|
|
624
|
-
reposList.textContent = '(no repos)';
|
|
625
|
-
reposList.style.color = '#666';
|
|
626
|
-
}
|
|
627
|
-
|
|
628
|
-
// Events
|
|
629
|
-
const eventsList = document.getElementById('events-list');
|
|
630
|
-
if (data.events && data.events.length > 0) {
|
|
631
|
-
eventsList.innerHTML = data.events.map(e =>
|
|
632
|
-
`<div class="item"><span style="color: #999; font-size: 10px;">${sanitize(e.substring(0, 80))}</span></div>`
|
|
633
|
-
).join('');
|
|
634
|
-
} else {
|
|
635
|
-
eventsList.textContent = '(no recent events)';
|
|
636
|
-
eventsList.style.color = '#666';
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
// Alerts
|
|
640
|
-
const alertsList = document.getElementById('alerts-list');
|
|
641
|
-
if (data.alerts && data.alerts.lines && data.alerts.lines.length > 0) {
|
|
642
|
-
alertsList.innerHTML = data.alerts.lines.map(line =>
|
|
643
|
-
`<div class="alert-line">${sanitize(line.substring(0, 120))}</div>`
|
|
644
|
-
).join('');
|
|
645
|
-
} else {
|
|
646
|
-
alertsList.innerHTML = '<div class="alert-none">(no alerts)</div>';
|
|
647
|
-
}
|
|
648
|
-
|
|
649
|
-
// Messages
|
|
650
|
-
const messagesList = document.getElementById('messages-list');
|
|
651
|
-
if (data.messages && data.messages.length > 0) {
|
|
652
|
-
messagesList.innerHTML = data.messages.map(m =>
|
|
653
|
-
`<div class="message fade-in"><span class="message-role">${sanitize(m.role)}</span><span class="message-time">${formatTimestamp(m.timestamp)}</span><div class="message-text">${sanitize(m.text)}</div></div>`
|
|
654
|
-
).join('');
|
|
655
|
-
} else {
|
|
656
|
-
messagesList.textContent = '(no messages)';
|
|
657
|
-
messagesList.style.color = '#666';
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
lastRefresh = Date.now();
|
|
661
|
-
} catch (e) {
|
|
662
|
-
console.error('Refresh error:', e);
|
|
663
|
-
}
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
async function handleInboxSubmit() {
|
|
667
|
-
const input = document.getElementById('inbox-input');
|
|
668
|
-
const button = document.getElementById('inbox-button');
|
|
669
|
-
const status = document.getElementById('inbox-status');
|
|
670
|
-
|
|
671
|
-
const text = input.value.trim();
|
|
672
|
-
if (!text) return;
|
|
673
|
-
|
|
674
|
-
button.disabled = true;
|
|
675
|
-
try {
|
|
676
|
-
const response = await fetch('/submit', {
|
|
677
|
-
method: 'POST',
|
|
678
|
-
headers: { 'Content-Type': 'application/json' },
|
|
679
|
-
body: JSON.stringify({ text })
|
|
680
|
-
});
|
|
681
|
-
if (response.ok) {
|
|
682
|
-
input.value = '';
|
|
683
|
-
status.style.display = 'block';
|
|
684
|
-
setTimeout(() => { status.style.display = 'none'; }, 3000);
|
|
685
|
-
}
|
|
686
|
-
} catch (e) {
|
|
687
|
-
console.error('Submit error:', e);
|
|
688
|
-
} finally {
|
|
689
|
-
button.disabled = false;
|
|
690
|
-
}
|
|
691
|
-
}
|
|
692
|
-
|
|
693
|
-
document.getElementById('inbox-button').addEventListener('click', handleInboxSubmit);
|
|
694
|
-
document.getElementById('inbox-input').addEventListener('keypress', (e) => {
|
|
695
|
-
if (e.key === 'Enter') handleInboxSubmit();
|
|
696
|
-
});
|
|
697
|
-
|
|
698
|
-
refresh();
|
|
699
|
-
setInterval(refresh, 3000);
|
|
700
|
-
</script>
|
|
701
|
-
</body>
|
|
702
|
-
</html>"""
|
|
703
|
-
self.send_response(200)
|
|
704
|
-
self.send_header("Content-Type", "text/html; charset=utf-8")
|
|
705
|
-
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
706
|
-
self.end_headers()
|
|
707
|
-
self.wfile.write(html.encode('utf-8'))
|
|
708
|
-
|
|
709
|
-
def serve_data(self):
|
|
710
|
-
"""Serve dashboard data as JSON."""
|
|
711
|
-
data = {
|
|
712
|
-
"watchdog": get_heartbeat_status(),
|
|
713
|
-
"monitor": get_monitor_heartbeat_status(),
|
|
714
|
-
"agents": get_fleet_agents(),
|
|
715
|
-
"repos": get_repos_status(),
|
|
716
|
-
"events": get_recent_events(),
|
|
717
|
-
"alerts": get_alerts(),
|
|
718
|
-
"messages": get_main_thread_messages(),
|
|
719
|
-
}
|
|
720
|
-
self.send_response(200)
|
|
721
|
-
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
722
|
-
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
723
|
-
self.end_headers()
|
|
724
|
-
self.wfile.write(json.dumps(data, default=str).encode('utf-8'))
|
|
725
|
-
|
|
726
|
-
def serve_agent(self):
|
|
727
|
-
"""Serve agent dispatch prompt and metadata via GET /agent?id=<agent_id>"""
|
|
728
|
-
try:
|
|
729
|
-
# Parse query string
|
|
730
|
-
query = urllib.parse.urlparse(self.path).query
|
|
731
|
-
params = urllib.parse.parse_qs(query)
|
|
732
|
-
agent_id = params.get('id', [None])[0]
|
|
733
|
-
|
|
734
|
-
if not agent_id:
|
|
735
|
-
self.send_response(400)
|
|
736
|
-
self.send_header("Content-Type", "application/json")
|
|
737
|
-
self.end_headers()
|
|
738
|
-
self.wfile.write(json.dumps({"error": "missing id parameter"}).encode('utf-8'))
|
|
739
|
-
return
|
|
740
|
-
|
|
741
|
-
# Extract dispatch prompt and metadata
|
|
742
|
-
data = extract_agent_dispatch_prompt(agent_id)
|
|
743
|
-
|
|
744
|
-
self.send_response(200)
|
|
745
|
-
self.send_header("Content-Type", "application/json; charset=utf-8")
|
|
746
|
-
self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
|
|
747
|
-
self.end_headers()
|
|
748
|
-
self.wfile.write(json.dumps(data, default=str).encode('utf-8'))
|
|
749
|
-
except Exception as e:
|
|
750
|
-
self.send_response(500)
|
|
751
|
-
self.send_header("Content-Type", "application/json")
|
|
752
|
-
self.end_headers()
|
|
753
|
-
self.wfile.write(json.dumps({"error": str(e)}).encode('utf-8'))
|
|
754
|
-
|
|
755
|
-
def handle_submit(self):
|
|
756
|
-
"""Handle /submit POST."""
|
|
757
|
-
try:
|
|
758
|
-
content_length = int(self.headers.get('Content-Length', 0))
|
|
759
|
-
if content_length > 10000: # 10KB limit
|
|
760
|
-
self.send_error(413)
|
|
761
|
-
return
|
|
762
|
-
|
|
763
|
-
body = self.rfile.read(content_length).decode('utf-8', errors='ignore')
|
|
764
|
-
data = json.loads(body)
|
|
765
|
-
text = data.get("text", "").strip()
|
|
766
|
-
|
|
767
|
-
if not text:
|
|
768
|
-
self.send_response(400)
|
|
769
|
-
self.end_headers()
|
|
770
|
-
return
|
|
771
|
-
|
|
772
|
-
# Append to inbox
|
|
773
|
-
inbox_content = f"- [{datetime.now().isoformat()}] {text}\n"
|
|
774
|
-
if not INBOX_FILE.exists():
|
|
775
|
-
INBOX_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
776
|
-
INBOX_FILE.write_text("# UI Inbox — orchestrator reads each turn / on /power\n\n")
|
|
777
|
-
|
|
778
|
-
with open(INBOX_FILE, 'a', encoding='utf-8') as f:
|
|
779
|
-
f.write(inbox_content)
|
|
780
|
-
|
|
781
|
-
self.send_response(200)
|
|
782
|
-
self.send_header("Content-Type", "application/json")
|
|
783
|
-
self.end_headers()
|
|
784
|
-
self.wfile.write(json.dumps({"ok": True}).encode('utf-8'))
|
|
785
|
-
except Exception as e:
|
|
786
|
-
self.send_response(500)
|
|
787
|
-
self.end_headers()
|
|
788
|
-
|
|
789
27
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
28
|
+
# Sys.path shim: add ui/ so sibling imports resolve both when this file is run
|
|
29
|
+
# as `python ui/serve.py` and when it is loaded by path via importlib (tests).
|
|
30
|
+
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
|
31
|
+
|
|
32
|
+
import config
|
|
33
|
+
config.reload()
|
|
34
|
+
|
|
35
|
+
import csrf
|
|
36
|
+
csrf.init()
|
|
37
|
+
|
|
38
|
+
import render
|
|
39
|
+
import collectors
|
|
40
|
+
import agents
|
|
41
|
+
import sse
|
|
42
|
+
|
|
43
|
+
# Fresh collector/snapshot state per serve import (restores the per-import
|
|
44
|
+
# isolation the monolith had; the sse module object is cached across re-imports).
|
|
45
|
+
sse.reset_state()
|
|
46
|
+
|
|
47
|
+
# Re-export sibling symbols so serve.X keeps resolving for tests + the handler.
|
|
48
|
+
# For config symbols, use __getattr__ to ensure they stay live through config.reload()
|
|
49
|
+
from csrf import *
|
|
50
|
+
from render import render_dashboard
|
|
51
|
+
from collectors import *
|
|
52
|
+
from agents import *
|
|
53
|
+
from sse import *
|
|
54
|
+
from collectors import (_snapshot_data, _snapshot_tracker,
|
|
55
|
+
_snapshot_orchestrator_status, drain_tracker_inbox)
|
|
56
|
+
from agents import _AGENT_ID_FORBIDDEN, _transcripts_fingerprint
|
|
57
|
+
from sse import (_sse_lock, _sse_clients, _latest_lock, _latest_snapshots,
|
|
58
|
+
_collector_lock, _collector_stop_event, _maybe_emit)
|
|
59
|
+
|
|
60
|
+
# HTTP handler + server entry.
|
|
61
|
+
import handler
|
|
62
|
+
from handler import DashboardHandler, run_server
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def __getattr__(name):
|
|
66
|
+
"""Forward config symbols to the live config module so they stay current
|
|
67
|
+
through config.reload() calls. This prevents the frozen `from config import *`
|
|
68
|
+
pattern which would go stale after any reload."""
|
|
69
|
+
import config
|
|
70
|
+
if hasattr(config, name):
|
|
71
|
+
return getattr(config, name)
|
|
72
|
+
raise AttributeError(f"module 'serve' has no attribute '{name}'")
|
|
803
73
|
|
|
804
74
|
|
|
805
75
|
if __name__ == "__main__":
|
|
76
|
+
print(f"Aesop Dashboard: http://127.0.0.1:{config.PORT}", flush=True)
|
|
77
|
+
print(f"Press Ctrl-C to stop", flush=True)
|
|
806
78
|
run_server()
|