@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
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# secretscan: allow-pattern-docs
|
|
3
|
+
"""
|
|
4
|
+
secret_scan.py — Pre-push secret/credential detection gate.
|
|
5
|
+
|
|
6
|
+
Modes:
|
|
7
|
+
secret_scan.py --staged [--repo PATH] Scan git staged files (default repo=cwd)
|
|
8
|
+
secret_scan.py --history [--repo PATH] Scan all blobs in git history
|
|
9
|
+
secret_scan.py PATH [PATH...] Scan files/dirs directly (recurse dirs)
|
|
10
|
+
|
|
11
|
+
Exit codes: 0=clean, 1=findings, 2=usage error
|
|
12
|
+
Output: one line per finding or summary (never prints full secrets)
|
|
13
|
+
|
|
14
|
+
Pragma escape hatch (defensive only):
|
|
15
|
+
If the literal string 'secretscan: allow-pattern-docs' appears in a file's first 10 lines
|
|
16
|
+
(any comment syntax: #, //, <!-- -->), pattern-based findings in that file are reported
|
|
17
|
+
as ALLOWED-DOC and do NOT cause exit 1. Filename-based findings (credential files like
|
|
18
|
+
.credentials.json, *.pem, id_rsa*, *.p12) stay fatal regardless. Use only for deliberate
|
|
19
|
+
pattern documentation; the pragma appears in git diffs and is a reviewable act.
|
|
20
|
+
|
|
21
|
+
NOTE: Public repo version has NO vault allowlist. All secrets block unless permitted by pragma.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import argparse
|
|
25
|
+
import os
|
|
26
|
+
import re
|
|
27
|
+
import sys
|
|
28
|
+
import subprocess
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Regex patterns for secret detection (case-insensitive where sensible)
|
|
33
|
+
PATTERNS = {
|
|
34
|
+
"pem_private_key": (r"-----BEGIN .* PRIVATE KEY-----", re.IGNORECASE),
|
|
35
|
+
"aws_access_key": (r"AKIA[0-9A-Z]{16}", 0),
|
|
36
|
+
"aws_secret_pattern": (
|
|
37
|
+
r"aws[_-]?secret[_-]?access[_-]?key\s*[:=]\s*[^\s\$\<\{]",
|
|
38
|
+
re.IGNORECASE,
|
|
39
|
+
),
|
|
40
|
+
"github_token": (
|
|
41
|
+
r"(ghp_|gho_|ghu_|ghs_|ghr_|github_pat_)[A-Za-z0-9_]{20,}",
|
|
42
|
+
0,
|
|
43
|
+
),
|
|
44
|
+
"slack_token": (r"xox[baprs]-[A-Za-z0-9-]{10,}", 0),
|
|
45
|
+
"openai_anthropic_key": (r"sk-[A-Za-z0-9_\-]{20,}", 0),
|
|
46
|
+
"generic_secret_assignment": (
|
|
47
|
+
r"(password|passwd|secret|api[_-]?key|token|authorization)\s*[:=]\s*[\"'](?!.*(?:xxx|changeme|your-|<|$\{|example)\b).{8,}[\"']",
|
|
48
|
+
re.IGNORECASE,
|
|
49
|
+
),
|
|
50
|
+
"connection_string": (r"://[^:]+:[^@/\s]+@(?!localhost(?:[:/]|$)|127\.0\.0\.1(?:[:/]|$)|example\.com(?:[:/]|$))[^\s]+", 0),
|
|
51
|
+
"env_access": (
|
|
52
|
+
r"(?i:(?:os\.getenv|os\.environ|System\.getenv|process\.env)\s*[\[\(][\"']?[A-Z_]*(?:password|secret|api[_-]?key|token|auth|key)[A-Z_0-9]*[\"']?[\)\]])",
|
|
53
|
+
0,
|
|
54
|
+
),
|
|
55
|
+
"env_assignment": (
|
|
56
|
+
r"(?i:[A-Z_]*(?:SECRET|TOKEN|PASSWORD|PASSWD|API[_-]?KEY|PRIVATE|CREDENTIAL|AUTH)[A-Z_0-9]*\s*=\s*)(?!.*(?:xxx|changeme|your-|example))[^\s].{8,}",
|
|
57
|
+
0,
|
|
58
|
+
),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
# File patterns that look like credentials (case-insensitive)
|
|
62
|
+
CREDENTIAL_FILENAMES = [
|
|
63
|
+
r"\.credentials.*",
|
|
64
|
+
r".*token.*",
|
|
65
|
+
r".*\.pem$",
|
|
66
|
+
r".*\.p12$",
|
|
67
|
+
r"id_rsa.*",
|
|
68
|
+
]
|
|
69
|
+
|
|
70
|
+
# Placeholders that don't count as secrets
|
|
71
|
+
PLACEHOLDERS = {"xxx", "changeme", "your-key-here", "example", "test", "demo"}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def has_pragma(filepath):
|
|
75
|
+
"""Check if file has 'secretscan: allow-pattern-docs' pragma in first 10 lines."""
|
|
76
|
+
try:
|
|
77
|
+
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
|
|
78
|
+
for i, line in enumerate(f):
|
|
79
|
+
if i >= 10: # Only check first 10 lines
|
|
80
|
+
break
|
|
81
|
+
if "secretscan: allow-pattern-docs" in line:
|
|
82
|
+
return True
|
|
83
|
+
except Exception:
|
|
84
|
+
pass
|
|
85
|
+
return False
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def is_binary_file(filepath):
|
|
89
|
+
"""Check if file is binary (contains null bytes)."""
|
|
90
|
+
try:
|
|
91
|
+
with open(filepath, "rb") as f:
|
|
92
|
+
return b"\x00" in f.read(8192)
|
|
93
|
+
except Exception:
|
|
94
|
+
return True
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def should_skip_file(filepath):
|
|
98
|
+
"""Check if file should be skipped (binary, too large, in .git/)."""
|
|
99
|
+
if ".git" in filepath.parts or ".git" in str(filepath):
|
|
100
|
+
return True
|
|
101
|
+
|
|
102
|
+
try:
|
|
103
|
+
stat = filepath.stat()
|
|
104
|
+
if stat.st_size > 1024 * 1024: # >1MB
|
|
105
|
+
return True
|
|
106
|
+
if is_binary_file(filepath):
|
|
107
|
+
return True
|
|
108
|
+
except Exception:
|
|
109
|
+
return True
|
|
110
|
+
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def is_placeholder(value):
|
|
115
|
+
"""Check if a string is a placeholder value (word-boundary aware)."""
|
|
116
|
+
lower_val = value.lower()
|
|
117
|
+
for p in PLACEHOLDERS:
|
|
118
|
+
if re.search(r'\b' + re.escape(p) + r'\b(?!\.)', lower_val):
|
|
119
|
+
return True
|
|
120
|
+
# Also check for template syntax
|
|
121
|
+
return bool(re.search(r"<[^>]*>|\$\{[^}]*\}", value))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def mask_secret(secret_str):
|
|
125
|
+
"""Mask secret: show first 4 chars + *** (or *** if <4 chars)."""
|
|
126
|
+
if len(secret_str) <= 4:
|
|
127
|
+
return "***"
|
|
128
|
+
return f"{secret_str[:4]}***"
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def is_env_file(filepath):
|
|
132
|
+
r"""Check if file is .env-like (basename matches ^\.env(\..*)?$ or *.env or *.properties)."""
|
|
133
|
+
name = filepath.name.lower()
|
|
134
|
+
return bool(
|
|
135
|
+
re.match(r"^\.env(\..*)?$", name) or
|
|
136
|
+
name.endswith(".env") or
|
|
137
|
+
name.endswith(".properties")
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def scan_file(filepath):
|
|
142
|
+
"""
|
|
143
|
+
Scan a single file for secrets.
|
|
144
|
+
Returns list of (line_num, rule, match_str, is_fatal).
|
|
145
|
+
is_fatal=True for credential filenames; is_fatal=False if pragma present and rule-based.
|
|
146
|
+
"""
|
|
147
|
+
findings = []
|
|
148
|
+
|
|
149
|
+
if should_skip_file(filepath):
|
|
150
|
+
return findings
|
|
151
|
+
|
|
152
|
+
# Check for pragma (applies only to rule-based findings, not filename findings)
|
|
153
|
+
has_file_pragma = has_pragma(filepath)
|
|
154
|
+
|
|
155
|
+
# Check if filename matches credential patterns (always fatal, pragma does NOT apply)
|
|
156
|
+
filename = filepath.name.lower()
|
|
157
|
+
for pattern in CREDENTIAL_FILENAMES:
|
|
158
|
+
if re.match(pattern, filename, re.IGNORECASE):
|
|
159
|
+
findings.append(
|
|
160
|
+
(0, "credential_filename", f"File name matches credential pattern: {filepath.name}", True)
|
|
161
|
+
)
|
|
162
|
+
break
|
|
163
|
+
|
|
164
|
+
try:
|
|
165
|
+
with open(filepath, "r", encoding="utf-8", errors="ignore") as f:
|
|
166
|
+
for line_num, line in enumerate(f, start=1):
|
|
167
|
+
for rule_name, (pattern, flags) in PATTERNS.items():
|
|
168
|
+
# Skip env_assignment rule if not an .env-like file
|
|
169
|
+
if rule_name == "env_assignment" and not is_env_file(filepath):
|
|
170
|
+
continue
|
|
171
|
+
|
|
172
|
+
matches = re.finditer(pattern, line, flags)
|
|
173
|
+
for match in matches:
|
|
174
|
+
match_str = match.group(0)
|
|
175
|
+
|
|
176
|
+
# Skip if it's a placeholder
|
|
177
|
+
if is_placeholder(match_str):
|
|
178
|
+
continue
|
|
179
|
+
|
|
180
|
+
# Mark as non-fatal if pragma present (rule-based only)
|
|
181
|
+
is_fatal = not has_file_pragma
|
|
182
|
+
findings.append((line_num, rule_name, match_str, is_fatal))
|
|
183
|
+
|
|
184
|
+
except Exception:
|
|
185
|
+
pass
|
|
186
|
+
|
|
187
|
+
return findings
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def get_staged_files(repo_path):
|
|
191
|
+
"""Get list of staged files from git repo."""
|
|
192
|
+
try:
|
|
193
|
+
result = subprocess.run(
|
|
194
|
+
["git", "diff", "--cached", "--name-only"],
|
|
195
|
+
cwd=repo_path,
|
|
196
|
+
capture_output=True,
|
|
197
|
+
text=True,
|
|
198
|
+
timeout=10,
|
|
199
|
+
)
|
|
200
|
+
if result.returncode != 0:
|
|
201
|
+
return []
|
|
202
|
+
return [
|
|
203
|
+
Path(repo_path) / f
|
|
204
|
+
for f in result.stdout.strip().split("\n")
|
|
205
|
+
if f.strip()
|
|
206
|
+
]
|
|
207
|
+
except Exception:
|
|
208
|
+
return []
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def get_history_files(repo_path):
|
|
212
|
+
"""Get all file contents from git history via git log -p."""
|
|
213
|
+
files_content = []
|
|
214
|
+
try:
|
|
215
|
+
# Use git log -p to get full diff history
|
|
216
|
+
result = subprocess.run(
|
|
217
|
+
["git", "log", "--all", "-p", "--reverse"],
|
|
218
|
+
cwd=repo_path,
|
|
219
|
+
capture_output=True,
|
|
220
|
+
text=True,
|
|
221
|
+
timeout=60,
|
|
222
|
+
)
|
|
223
|
+
if result.returncode == 0:
|
|
224
|
+
# Parse the git log output: each file appears as +++ b/path/to/file followed by its content
|
|
225
|
+
current_file = None
|
|
226
|
+
current_content = []
|
|
227
|
+
for line in result.stdout.split("\n"):
|
|
228
|
+
if line.startswith("+++ b/"):
|
|
229
|
+
if current_file and current_content:
|
|
230
|
+
files_content.append((current_file, "\n".join(current_content)))
|
|
231
|
+
current_file = line[6:] # Remove "+++ b/"
|
|
232
|
+
current_content = []
|
|
233
|
+
elif current_file and line.startswith("+") and not line.startswith("+++"):
|
|
234
|
+
# Content line (added), strip the leading +
|
|
235
|
+
current_content.append(line[1:])
|
|
236
|
+
elif current_file and line.startswith(" "):
|
|
237
|
+
# Context line, keep it as-is (strip leading space)
|
|
238
|
+
current_content.append(line[1:])
|
|
239
|
+
if current_file and current_content:
|
|
240
|
+
files_content.append((current_file, "\n".join(current_content)))
|
|
241
|
+
except Exception:
|
|
242
|
+
pass
|
|
243
|
+
return files_content
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def scan_paths(paths):
|
|
247
|
+
"""Recursively scan paths (files and directories)."""
|
|
248
|
+
files_to_scan = []
|
|
249
|
+
for path_str in paths:
|
|
250
|
+
path = Path(path_str).resolve()
|
|
251
|
+
if path.is_file():
|
|
252
|
+
files_to_scan.append(path)
|
|
253
|
+
elif path.is_dir():
|
|
254
|
+
files_to_scan.extend(path.rglob("*"))
|
|
255
|
+
return [p for p in files_to_scan if p.is_file()]
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def scan_content(content):
|
|
259
|
+
"""Scan raw content for secrets (used by history scanning)."""
|
|
260
|
+
findings = []
|
|
261
|
+
for line_num, line in enumerate(content.split("\n"), start=1):
|
|
262
|
+
for rule_name, (pattern, flags) in PATTERNS.items():
|
|
263
|
+
matches = re.finditer(pattern, line, flags)
|
|
264
|
+
for match in matches:
|
|
265
|
+
match_str = match.group(0)
|
|
266
|
+
if is_placeholder(match_str):
|
|
267
|
+
continue
|
|
268
|
+
findings.append((line_num, rule_name, match_str, True))
|
|
269
|
+
return findings
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def main():
|
|
273
|
+
parser = argparse.ArgumentParser(
|
|
274
|
+
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
|
|
275
|
+
)
|
|
276
|
+
parser.add_argument(
|
|
277
|
+
"--staged",
|
|
278
|
+
action="store_true",
|
|
279
|
+
help="Scan git staged files (requires --repo or uses cwd)",
|
|
280
|
+
)
|
|
281
|
+
parser.add_argument(
|
|
282
|
+
"--history",
|
|
283
|
+
action="store_true",
|
|
284
|
+
help="Scan all blobs in git history (requires --repo or uses cwd)",
|
|
285
|
+
)
|
|
286
|
+
parser.add_argument(
|
|
287
|
+
"--repo",
|
|
288
|
+
default=os.getcwd(),
|
|
289
|
+
help="Git repo path (default: current directory)",
|
|
290
|
+
)
|
|
291
|
+
parser.add_argument(
|
|
292
|
+
"paths", nargs="*", help="Paths to scan directly (files or directories)"
|
|
293
|
+
)
|
|
294
|
+
|
|
295
|
+
args = parser.parse_args()
|
|
296
|
+
|
|
297
|
+
# Validate usage
|
|
298
|
+
if sum([args.staged, args.history, bool(args.paths)]) != 1:
|
|
299
|
+
print("ERROR: Use exactly one of --staged, --history, or path arguments", file=sys.stderr)
|
|
300
|
+
sys.exit(2)
|
|
301
|
+
|
|
302
|
+
# Collect files to scan
|
|
303
|
+
if args.staged:
|
|
304
|
+
files = get_staged_files(args.repo)
|
|
305
|
+
elif args.history:
|
|
306
|
+
# History mode: scan all files from git log
|
|
307
|
+
history_files = get_history_files(args.repo)
|
|
308
|
+
files = [] # We'll handle history differently
|
|
309
|
+
else:
|
|
310
|
+
files = scan_paths(args.paths)
|
|
311
|
+
|
|
312
|
+
# Scan files
|
|
313
|
+
all_findings = []
|
|
314
|
+
fatal_findings = []
|
|
315
|
+
allowed_doc_count = 0
|
|
316
|
+
|
|
317
|
+
if args.history:
|
|
318
|
+
# History scanning mode
|
|
319
|
+
for filepath, content in history_files:
|
|
320
|
+
findings = scan_content(content)
|
|
321
|
+
for line_num, rule, match_str, is_fatal in findings:
|
|
322
|
+
all_findings.append((filepath, line_num, rule, match_str, is_fatal))
|
|
323
|
+
if is_fatal:
|
|
324
|
+
fatal_findings.append((filepath, line_num, rule, match_str))
|
|
325
|
+
else:
|
|
326
|
+
# Regular file scanning mode
|
|
327
|
+
for filepath in files:
|
|
328
|
+
findings = scan_file(filepath)
|
|
329
|
+
for line_num, rule, match_str, is_fatal in findings:
|
|
330
|
+
all_findings.append((filepath, line_num, rule, match_str, is_fatal))
|
|
331
|
+
if is_fatal:
|
|
332
|
+
fatal_findings.append((filepath, line_num, rule, match_str))
|
|
333
|
+
else:
|
|
334
|
+
allowed_doc_count += 1
|
|
335
|
+
|
|
336
|
+
# Output findings
|
|
337
|
+
for filepath, line_num, rule, match_str, is_fatal in all_findings:
|
|
338
|
+
masked = mask_secret(match_str)
|
|
339
|
+
if is_fatal:
|
|
340
|
+
print(f"HIGH {filepath}:{line_num} {rule} ({masked})")
|
|
341
|
+
else:
|
|
342
|
+
print(f"ALLOWED-DOC {filepath}:{line_num} {rule} ({masked})")
|
|
343
|
+
|
|
344
|
+
# Summary and exit
|
|
345
|
+
file_count = len(files) if not args.history else len(set(f for f, _, _, _, _ in all_findings))
|
|
346
|
+
|
|
347
|
+
if len(fatal_findings) == 0:
|
|
348
|
+
if allowed_doc_count == 0:
|
|
349
|
+
if args.history:
|
|
350
|
+
print(f"CLEAN: git history scanned")
|
|
351
|
+
else:
|
|
352
|
+
print(f"CLEAN: {file_count} files scanned")
|
|
353
|
+
else:
|
|
354
|
+
pragma_file_count = len(set(f for f, _, _, _, is_fatal in all_findings if not is_fatal))
|
|
355
|
+
print(f"CLEAN: scanned ({allowed_doc_count} allowed-doc findings in {pragma_file_count} pragma files)")
|
|
356
|
+
sys.exit(0)
|
|
357
|
+
else:
|
|
358
|
+
print(f"FOUND: {len(fatal_findings)} secret(s)")
|
|
359
|
+
sys.exit(1)
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
if __name__ == "__main__":
|
|
363
|
+
main()
|
package/ui/README.md
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# Aesop Web Dashboard
|
|
2
|
+
|
|
3
|
+
A lightweight, self-contained web dashboard for real-time fleet monitoring. Replaces the bash TUI with a modern, responsive HTML interface that displays heartbeats, active agents, security alerts, and inbox status.
|
|
4
|
+
|
|
5
|
+
## Launch
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
export AESOP_ROOT=$HOME/aesop
|
|
9
|
+
python $AESOP_ROOT/ui/serve.py
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Prints `Dashboard: http://localhost:8770` and serves until Ctrl-C.
|
|
13
|
+
|
|
14
|
+
### Configuration
|
|
15
|
+
|
|
16
|
+
The dashboard reads configuration from `aesop.config.json` if present. Key paths:
|
|
17
|
+
|
|
18
|
+
- **`AESOP_ROOT`** (env var): Path to aesop installation (default: `$HOME/aesop`)
|
|
19
|
+
- **`PORT`** (env var): Dashboard port (default: `8770`)
|
|
20
|
+
- **`AESOP_TRANSCRIPTS_ROOT`** (env var): Path to Claude session transcripts (default: `~/.claude/projects`)
|
|
21
|
+
|
|
22
|
+
Example with custom paths:
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
export AESOP_ROOT=/opt/aesop
|
|
26
|
+
export AESOP_TRANSCRIPTS_ROOT=/home/user/.claude/projects
|
|
27
|
+
export PORT=9000
|
|
28
|
+
python $AESOP_ROOT/ui/serve.py
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
## Features
|
|
32
|
+
|
|
33
|
+
### Header Status
|
|
34
|
+
- **Watchdog Status**: Daemon heartbeat age + ALIVE/STALE (300s threshold)
|
|
35
|
+
- **Monitor Status**: Orchestration monitor liveness + age (3600s threshold)
|
|
36
|
+
- **Security Alerts**: Count of unreviewed alerts
|
|
37
|
+
- **Running Agents**: Number of active subagents (<2 min old)
|
|
38
|
+
|
|
39
|
+
### Panels
|
|
40
|
+
|
|
41
|
+
1. **Inbox** (top): Text input → Send button appends to `state/ui-inbox.md`. Orchestrator reads each turn.
|
|
42
|
+
2. **Fleet Agents**: Currently-running subagents with status and hint text (refreshed every 3s)
|
|
43
|
+
3. **Repos Status**: Repository sync status from `state/.watchdog-repos.json`
|
|
44
|
+
4. **Recent Events**: Last 8 lines from `state/FLEET-BACKUP.log`
|
|
45
|
+
5. **Security Alerts**: Unreviewed lines from `scan/SECURITY-ALERTS.log` (skips NOTE:/RESOLVED-FP)
|
|
46
|
+
6. **Main-Thread Prompts**: Last ~12 messages from newest session JSONL in transcripts root
|
|
47
|
+
|
|
48
|
+
### Agent Details
|
|
49
|
+
|
|
50
|
+
Click any agent row in the **Fleet Agents** panel to expand and view full dispatch details:
|
|
51
|
+
|
|
52
|
+
- **Dispatcher**: Who launched this agent (main thread or parent agent)
|
|
53
|
+
- **Model**: LLM model used for the agent
|
|
54
|
+
- **Messages**: Total NDJSON message count in transcript
|
|
55
|
+
- **Prompt**: Full dispatch prompt (expandable container with scroll)
|
|
56
|
+
|
|
57
|
+
#### API: `/agent?id=<agent_id>`
|
|
58
|
+
|
|
59
|
+
GET endpoint returns JSON with agent metadata:
|
|
60
|
+
|
|
61
|
+
```json
|
|
62
|
+
{
|
|
63
|
+
"id": "a77b995bcdb9",
|
|
64
|
+
"dispatch_prompt": "full prompt text here...",
|
|
65
|
+
"dispatcher": "main thread",
|
|
66
|
+
"model": "haiku",
|
|
67
|
+
"message_count": 42,
|
|
68
|
+
"first_seen": 1720700000,
|
|
69
|
+
"last_activity": 1720700300
|
|
70
|
+
}
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
**Note**: Agent IDs are prefix-matched via glob (truncated IDs in the dashboard resolve to full transcripts on disk).
|
|
74
|
+
|
|
75
|
+
### Inbox Contract
|
|
76
|
+
|
|
77
|
+
POST `/submit` appends timestamped line to `state/ui-inbox.md`:
|
|
78
|
+
|
|
79
|
+
```markdown
|
|
80
|
+
# UI Inbox — orchestrator reads each turn / on /power
|
|
81
|
+
|
|
82
|
+
- [2026-07-11T14:23:45.123456] user's task here
|
|
83
|
+
- [2026-07-11T14:24:12.654321] another task
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Requirements
|
|
87
|
+
|
|
88
|
+
- **Python 3.10+**: stdlib-only (no external dependencies)
|
|
89
|
+
- **Node.js v18+** (optional): required for agent detection via `dash/dash-extra.mjs`
|
|
90
|
+
|
|
91
|
+
## Robustness
|
|
92
|
+
|
|
93
|
+
- Every data source is wrapped with exception handling
|
|
94
|
+
- Missing or locked files → placeholder text (never 500 error)
|
|
95
|
+
- `/data` endpoint always returns valid JSON
|
|
96
|
+
- Auto-refresh every 3s via client-side fetch
|
|
97
|
+
|
|
98
|
+
## Path Configuration
|
|
99
|
+
|
|
100
|
+
If you have a non-standard setup, set paths in `aesop.config.json`:
|
|
101
|
+
|
|
102
|
+
```json
|
|
103
|
+
{
|
|
104
|
+
"state_root": "/path/to/state",
|
|
105
|
+
"scan_root": "/path/to/scan",
|
|
106
|
+
"transcripts_root": "/path/to/transcripts"
|
|
107
|
+
}
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
Or use environment variables:
|
|
111
|
+
|
|
112
|
+
```bash
|
|
113
|
+
export AESOP_TRANSCRIPTS_ROOT=/my/custom/transcripts/path
|
|
114
|
+
python ui/serve.py
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
## Troubleshooting
|
|
118
|
+
|
|
119
|
+
### "Agents panel unavailable"
|
|
120
|
+
- Ensure `node` is on PATH and `dash/dash-extra.mjs` exists
|
|
121
|
+
- Install Node.js v18+
|
|
122
|
+
|
|
123
|
+
### Dashboard shows no data
|
|
124
|
+
- Verify `AESOP_ROOT` is set correctly: `echo $AESOP_ROOT`
|
|
125
|
+
- Check that `state/` directory exists and is readable
|
|
126
|
+
- Verify `aesop.config.json` is valid JSON (if present)
|
|
127
|
+
|
|
128
|
+
### Can't connect to http://localhost:8770
|
|
129
|
+
- Check port is not in use: `lsof -i :8770` (macOS/Linux) or `netstat -ano | findstr :8770` (Windows)
|
|
130
|
+
- Try a different port: `export PORT=9000`
|
|
131
|
+
|
|
132
|
+
## Integration with Claude Code
|
|
133
|
+
|
|
134
|
+
To display main-thread transcripts, ensure `AESOP_TRANSCRIPTS_ROOT` points to your Claude session directory. Example:
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
export AESOP_TRANSCRIPTS_ROOT=$HOME/.claude/projects/my-project
|
|
138
|
+
python $AESOP_ROOT/ui/serve.py
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
See the main Aesop README for full orchestration setup.
|