@matt82198/aesop 0.1.0-beta.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/CHANGELOG.md +126 -0
- package/LICENSE +21 -0
- package/README.md +333 -0
- package/aesop.config.example.json +47 -0
- package/bin/cli.js +112 -0
- package/daemons/backup-fleet.sh +173 -0
- package/daemons/run-watchdog.sh +37 -0
- package/dash/dash-extra.mjs +184 -0
- package/dash/watchdog-gui.sh +141 -0
- package/docs/CARDINAL-RULES.md +146 -0
- package/docs/DISPATCH-MODEL.md +180 -0
- package/docs/PUBLISHING.md +150 -0
- package/docs/av-resilience.md +246 -0
- package/monitor/CHARTER.md +66 -0
- package/monitor/collect-signals.mjs +137 -0
- package/package.json +50 -0
- package/tools/launch_tui.py +245 -0
- package/tools/secret_scan.py +363 -0
- package/ui/README.md +141 -0
- package/ui/serve.py +806 -0
package/ui/serve.py
ADDED
|
@@ -0,0 +1,806 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Aesop Web Dashboard — stdlib-only local observability.
|
|
4
|
+
Serves a dark-theme HTML dashboard on a configurable port (default 8770).
|
|
5
|
+
No external dependencies. Auto-refresh every 3s via fetch('/data') → JSON.
|
|
6
|
+
|
|
7
|
+
Configuration:
|
|
8
|
+
- AESOP_ROOT: env var pointing to aesop installation (default: $HOME/aesop)
|
|
9
|
+
- aesop.config.json: optional config file with paths and settings
|
|
10
|
+
- PORT env var: override dashboard port (default: 8770)
|
|
11
|
+
- AESOP_TRANSCRIPTS_ROOT: env var for Claude transcript directory
|
|
12
|
+
"""
|
|
13
|
+
import http.server
|
|
14
|
+
import json
|
|
15
|
+
import os
|
|
16
|
+
import re
|
|
17
|
+
import subprocess
|
|
18
|
+
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
|
+
|
|
790
|
+
def run_server():
|
|
791
|
+
"""Start the HTTP server."""
|
|
792
|
+
addr = ("127.0.0.1", PORT)
|
|
793
|
+
httpd = http.server.HTTPServer(addr, DashboardHandler)
|
|
794
|
+
print(f"Dashboard: http://localhost:{PORT}")
|
|
795
|
+
print(f"AESOP_ROOT: {AESOP_ROOT}")
|
|
796
|
+
print(f"Transcripts: {TRANSCRIPTS_ROOT}")
|
|
797
|
+
print(f"Press Ctrl-C to stop")
|
|
798
|
+
try:
|
|
799
|
+
httpd.serve_forever()
|
|
800
|
+
except KeyboardInterrupt:
|
|
801
|
+
print("\nShutdown complete.")
|
|
802
|
+
sys.exit(0)
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
if __name__ == "__main__":
|
|
806
|
+
run_server()
|