@katyella/legio 0.1.0
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 +422 -0
- package/LICENSE +21 -0
- package/README.md +555 -0
- package/agents/builder.md +141 -0
- package/agents/coordinator.md +351 -0
- package/agents/cto.md +196 -0
- package/agents/gateway.md +276 -0
- package/agents/lead.md +281 -0
- package/agents/merger.md +156 -0
- package/agents/monitor.md +212 -0
- package/agents/reviewer.md +142 -0
- package/agents/scout.md +131 -0
- package/agents/supervisor.md +416 -0
- package/bin/legio.mjs +38 -0
- package/package.json +77 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +102 -0
- package/src/agents/hooks-deployer.test.ts +1820 -0
- package/src/agents/hooks-deployer.ts +574 -0
- package/src/agents/identity.test.ts +614 -0
- package/src/agents/identity.ts +385 -0
- package/src/agents/lifecycle.test.ts +202 -0
- package/src/agents/lifecycle.ts +184 -0
- package/src/agents/manifest.test.ts +558 -0
- package/src/agents/manifest.ts +297 -0
- package/src/agents/overlay.test.ts +592 -0
- package/src/agents/overlay.ts +316 -0
- package/src/beads/client.test.ts +210 -0
- package/src/beads/client.ts +227 -0
- package/src/beads/molecules.test.ts +320 -0
- package/src/beads/molecules.ts +209 -0
- package/src/commands/agents.test.ts +325 -0
- package/src/commands/agents.ts +286 -0
- package/src/commands/clean.test.ts +730 -0
- package/src/commands/clean.ts +653 -0
- package/src/commands/completions.test.ts +346 -0
- package/src/commands/completions.ts +950 -0
- package/src/commands/coordinator.test.ts +1524 -0
- package/src/commands/coordinator.ts +880 -0
- package/src/commands/costs.test.ts +1015 -0
- package/src/commands/costs.ts +473 -0
- package/src/commands/dashboard.test.ts +94 -0
- package/src/commands/dashboard.ts +607 -0
- package/src/commands/doctor.test.ts +295 -0
- package/src/commands/doctor.ts +213 -0
- package/src/commands/down.test.ts +308 -0
- package/src/commands/down.ts +124 -0
- package/src/commands/errors.test.ts +648 -0
- package/src/commands/errors.ts +255 -0
- package/src/commands/feed.test.ts +579 -0
- package/src/commands/feed.ts +368 -0
- package/src/commands/gateway.test.ts +698 -0
- package/src/commands/gateway.ts +419 -0
- package/src/commands/group.test.ts +262 -0
- package/src/commands/group.ts +539 -0
- package/src/commands/hooks.test.ts +292 -0
- package/src/commands/hooks.ts +210 -0
- package/src/commands/init.test.ts +211 -0
- package/src/commands/init.ts +622 -0
- package/src/commands/inspect.test.ts +670 -0
- package/src/commands/inspect.ts +455 -0
- package/src/commands/log.test.ts +1556 -0
- package/src/commands/log.ts +752 -0
- package/src/commands/logs.test.ts +379 -0
- package/src/commands/logs.ts +544 -0
- package/src/commands/mail.test.ts +1726 -0
- package/src/commands/mail.ts +926 -0
- package/src/commands/merge.test.ts +676 -0
- package/src/commands/merge.ts +374 -0
- package/src/commands/metrics.test.ts +444 -0
- package/src/commands/metrics.ts +150 -0
- package/src/commands/monitor.test.ts +151 -0
- package/src/commands/monitor.ts +394 -0
- package/src/commands/nudge.test.ts +230 -0
- package/src/commands/nudge.ts +373 -0
- package/src/commands/prime.test.ts +467 -0
- package/src/commands/prime.ts +386 -0
- package/src/commands/replay.test.ts +742 -0
- package/src/commands/replay.ts +367 -0
- package/src/commands/run.test.ts +443 -0
- package/src/commands/run.ts +365 -0
- package/src/commands/server.test.ts +626 -0
- package/src/commands/server.ts +298 -0
- package/src/commands/sling.test.ts +810 -0
- package/src/commands/sling.ts +700 -0
- package/src/commands/spec.test.ts +206 -0
- package/src/commands/spec.ts +171 -0
- package/src/commands/status.test.ts +276 -0
- package/src/commands/status.ts +339 -0
- package/src/commands/stop.test.ts +357 -0
- package/src/commands/stop.ts +119 -0
- package/src/commands/supervisor.test.ts +186 -0
- package/src/commands/supervisor.ts +544 -0
- package/src/commands/trace.test.ts +746 -0
- package/src/commands/trace.ts +332 -0
- package/src/commands/up.test.ts +597 -0
- package/src/commands/up.ts +275 -0
- package/src/commands/watch.test.ts +152 -0
- package/src/commands/watch.ts +238 -0
- package/src/commands/worktree.test.ts +648 -0
- package/src/commands/worktree.ts +266 -0
- package/src/config.test.ts +496 -0
- package/src/config.ts +616 -0
- package/src/doctor/agents.test.ts +448 -0
- package/src/doctor/agents.ts +396 -0
- package/src/doctor/config-check.test.ts +184 -0
- package/src/doctor/config-check.ts +185 -0
- package/src/doctor/consistency.test.ts +645 -0
- package/src/doctor/consistency.ts +294 -0
- package/src/doctor/databases.test.ts +284 -0
- package/src/doctor/databases.ts +211 -0
- package/src/doctor/dependencies.test.ts +150 -0
- package/src/doctor/dependencies.ts +179 -0
- package/src/doctor/logs.test.ts +244 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +210 -0
- package/src/doctor/merge-queue.ts +144 -0
- package/src/doctor/structure.test.ts +285 -0
- package/src/doctor/structure.ts +195 -0
- package/src/doctor/types.ts +37 -0
- package/src/doctor/version.test.ts +130 -0
- package/src/doctor/version.ts +131 -0
- package/src/e2e/chat-flow.test.ts +346 -0
- package/src/e2e/init-sling-lifecycle.test.ts +288 -0
- package/src/errors.test.ts +21 -0
- package/src/errors.ts +246 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +344 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/global-setup.ts +14 -0
- package/src/index.ts +339 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/logging/color.test.ts +118 -0
- package/src/logging/color.ts +71 -0
- package/src/logging/logger.test.ts +812 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +258 -0
- package/src/logging/reporter.ts +109 -0
- package/src/logging/sanitizer.test.ts +190 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/broadcast.test.ts +203 -0
- package/src/mail/broadcast.ts +92 -0
- package/src/mail/client.test.ts +873 -0
- package/src/mail/client.ts +236 -0
- package/src/mail/store.test.ts +815 -0
- package/src/mail/store.ts +402 -0
- package/src/merge/queue.test.ts +449 -0
- package/src/merge/queue.ts +262 -0
- package/src/merge/resolver.test.ts +1453 -0
- package/src/merge/resolver.ts +759 -0
- package/src/metrics/store.test.ts +1167 -0
- package/src/metrics/store.ts +511 -0
- package/src/metrics/summary.test.ts +397 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +643 -0
- package/src/metrics/transcript.ts +351 -0
- package/src/mulch/client.test.ts +547 -0
- package/src/mulch/client.ts +416 -0
- package/src/server/audit-store.test.ts +384 -0
- package/src/server/audit-store.ts +257 -0
- package/src/server/headless.test.ts +180 -0
- package/src/server/headless.ts +151 -0
- package/src/server/index.test.ts +241 -0
- package/src/server/index.ts +317 -0
- package/src/server/public/app.js +187 -0
- package/src/server/public/apple-touch-icon.png +0 -0
- package/src/server/public/components/agent-badge.js +37 -0
- package/src/server/public/components/data-table.js +114 -0
- package/src/server/public/components/gateway-chat.js +256 -0
- package/src/server/public/components/issue-card.js +96 -0
- package/src/server/public/components/layout.js +88 -0
- package/src/server/public/components/message-bubble.js +120 -0
- package/src/server/public/components/stat-card.js +26 -0
- package/src/server/public/components/terminal-panel.js +140 -0
- package/src/server/public/favicon-16.png +0 -0
- package/src/server/public/favicon-32.png +0 -0
- package/src/server/public/favicon.ico +0 -0
- package/src/server/public/favicon.png +0 -0
- package/src/server/public/index.html +64 -0
- package/src/server/public/lib/api.js +35 -0
- package/src/server/public/lib/markdown.js +8 -0
- package/src/server/public/lib/preact-setup.js +8 -0
- package/src/server/public/lib/state.js +99 -0
- package/src/server/public/lib/utils.js +309 -0
- package/src/server/public/lib/ws.js +79 -0
- package/src/server/public/views/chat.js +983 -0
- package/src/server/public/views/costs.js +692 -0
- package/src/server/public/views/dashboard.js +781 -0
- package/src/server/public/views/gateway-chat.js +622 -0
- package/src/server/public/views/inspect.js +399 -0
- package/src/server/public/views/issues.js +470 -0
- package/src/server/public/views/setup.js +94 -0
- package/src/server/public/views/task-detail.js +422 -0
- package/src/server/routes.test.ts +3816 -0
- package/src/server/routes.ts +1964 -0
- package/src/server/websocket.test.ts +288 -0
- package/src/server/websocket.ts +196 -0
- package/src/sessions/compat.test.ts +109 -0
- package/src/sessions/compat.ts +17 -0
- package/src/sessions/store.test.ts +969 -0
- package/src/sessions/store.ts +480 -0
- package/src/test-helpers.test.ts +97 -0
- package/src/test-helpers.ts +143 -0
- package/src/types.ts +708 -0
- package/src/watchdog/daemon.test.ts +1233 -0
- package/src/watchdog/daemon.ts +533 -0
- package/src/watchdog/health.test.ts +371 -0
- package/src/watchdog/health.ts +248 -0
- package/src/watchdog/triage.test.ts +162 -0
- package/src/watchdog/triage.ts +193 -0
- package/src/worktree/manager.test.ts +444 -0
- package/src/worktree/manager.ts +224 -0
- package/src/worktree/tmux.test.ts +1238 -0
- package/src/worktree/tmux.ts +644 -0
- package/templates/CLAUDE.md.tmpl +89 -0
- package/templates/hooks.json.tmpl +132 -0
- package/templates/overlay.md.tmpl +79 -0
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
// terminal-panel.js — Standalone TerminalPanel component
|
|
2
|
+
// Extracted from coordinator-chat.js so ChatView (and others) can use it independently.
|
|
3
|
+
|
|
4
|
+
import { html, useEffect, useRef, useState } from "../lib/preact-setup.js";
|
|
5
|
+
|
|
6
|
+
function stripAnsi(str) {
|
|
7
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: intentional ANSI escape strip
|
|
8
|
+
return str.replace(/\x1b\[[0-9;]*[mGKHF]/g, "");
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function diffCapture(baselineText, currentText) {
|
|
12
|
+
const baselineLines = baselineText.trimEnd().split("\n");
|
|
13
|
+
const currentLines = currentText.trimEnd().split("\n");
|
|
14
|
+
const anchorLen = Math.min(3, baselineLines.length);
|
|
15
|
+
const anchor = baselineLines.slice(-anchorLen);
|
|
16
|
+
|
|
17
|
+
// Search for the anchor sequence in current capture (prefer latest match)
|
|
18
|
+
for (let i = currentLines.length - anchorLen; i >= 0; i--) {
|
|
19
|
+
let match = true;
|
|
20
|
+
for (let j = 0; j < anchorLen; j++) {
|
|
21
|
+
if (currentLines[i + j] !== anchor[j]) {
|
|
22
|
+
match = false;
|
|
23
|
+
break;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (match) {
|
|
27
|
+
return currentLines.slice(i + anchorLen).join("\n");
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
// Baseline scrolled off — show tail
|
|
31
|
+
return currentLines.slice(-20).join("\n");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// TerminalPanel — collapsible terminal capture sub-component
|
|
35
|
+
// Props:
|
|
36
|
+
// chatTarget {string} — agent name to capture terminal from
|
|
37
|
+
// thinking {boolean} — when true, streams diff-based new output
|
|
38
|
+
export function TerminalPanel({ chatTarget, thinking }) {
|
|
39
|
+
const [expanded, setExpanded] = useState(false);
|
|
40
|
+
const [streamText, setStreamText] = useState("");
|
|
41
|
+
const [loading, setLoading] = useState(false);
|
|
42
|
+
const baselineCaptureRef = useRef(null);
|
|
43
|
+
const terminalRef = useRef(null);
|
|
44
|
+
|
|
45
|
+
// Reset when chatTarget changes
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
setStreamText("");
|
|
48
|
+
setLoading(false);
|
|
49
|
+
baselineCaptureRef.current = null;
|
|
50
|
+
}, [chatTarget]);
|
|
51
|
+
|
|
52
|
+
// Poll terminal capture when thinking OR expanded; clear when neither
|
|
53
|
+
useEffect(() => {
|
|
54
|
+
if (!thinking && !expanded) {
|
|
55
|
+
setStreamText("");
|
|
56
|
+
setLoading(false);
|
|
57
|
+
baselineCaptureRef.current = null;
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let cancelled = false;
|
|
62
|
+
setLoading(true);
|
|
63
|
+
|
|
64
|
+
async function pollCapture() {
|
|
65
|
+
try {
|
|
66
|
+
const res = await fetch(`/api/terminal/capture?agent=${chatTarget}&lines=80`);
|
|
67
|
+
if (!res.ok || cancelled) return;
|
|
68
|
+
const data = await res.json();
|
|
69
|
+
const output = stripAnsi(data.output || "");
|
|
70
|
+
if (!cancelled) setLoading(false);
|
|
71
|
+
|
|
72
|
+
if (thinking) {
|
|
73
|
+
// Streaming mode: diff against baseline to show new output
|
|
74
|
+
if (baselineCaptureRef.current === null) {
|
|
75
|
+
baselineCaptureRef.current = output;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
const delta = diffCapture(baselineCaptureRef.current, output);
|
|
79
|
+
if (!cancelled && delta.trim()) {
|
|
80
|
+
setStreamText(delta);
|
|
81
|
+
}
|
|
82
|
+
} else {
|
|
83
|
+
// Expanded viewer mode: show full terminal capture directly
|
|
84
|
+
if (!cancelled && output.trim()) {
|
|
85
|
+
setStreamText(output);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
} catch (_err) {
|
|
89
|
+
// non-fatal — capture may fail if coordinator tmux not ready
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
pollCapture();
|
|
94
|
+
const interval = setInterval(pollCapture, 1500);
|
|
95
|
+
return () => {
|
|
96
|
+
cancelled = true;
|
|
97
|
+
clearInterval(interval);
|
|
98
|
+
};
|
|
99
|
+
}, [thinking, expanded, chatTarget]);
|
|
100
|
+
|
|
101
|
+
// Auto-scroll terminal to bottom when new output arrives
|
|
102
|
+
useEffect(() => {
|
|
103
|
+
const el = terminalRef.current;
|
|
104
|
+
if (el) el.scrollTop = el.scrollHeight;
|
|
105
|
+
}, [streamText]);
|
|
106
|
+
|
|
107
|
+
return html`
|
|
108
|
+
<div class="border-t border-[#2a2a2a] shrink-0">
|
|
109
|
+
<div
|
|
110
|
+
class="flex items-center gap-2 px-3 py-1.5 cursor-pointer hover:bg-white/5"
|
|
111
|
+
onClick=${() => setExpanded((prev) => !prev)}
|
|
112
|
+
>
|
|
113
|
+
<span
|
|
114
|
+
class=${
|
|
115
|
+
"w-2 h-2 rounded-full flex-shrink-0 " +
|
|
116
|
+
(thinking ? "bg-yellow-500 animate-pulse" : "bg-[#333]")
|
|
117
|
+
}
|
|
118
|
+
></span>
|
|
119
|
+
<span class="text-xs text-[#666]">Terminal</span>
|
|
120
|
+
${thinking ? html`<span class="text-xs text-yellow-500 animate-pulse">active</span>` : null}
|
|
121
|
+
<span class="ml-auto text-xs text-[#444]">${expanded ? "\u25b2" : "\u25bc"}</span>
|
|
122
|
+
</div>
|
|
123
|
+
${
|
|
124
|
+
expanded
|
|
125
|
+
? html`
|
|
126
|
+
<div ref=${terminalRef} class="max-h-[200px] overflow-y-auto px-3 pb-2">
|
|
127
|
+
${
|
|
128
|
+
streamText
|
|
129
|
+
? html`<pre class="text-xs text-[#ccc] font-mono whitespace-pre-wrap break-words">${streamText}</pre>`
|
|
130
|
+
: loading
|
|
131
|
+
? html`<div class="text-xs text-[#666] py-1 italic animate-pulse">Connecting...</div>`
|
|
132
|
+
: html`<div class="text-xs text-[#444] py-1 italic">No output yet</div>`
|
|
133
|
+
}
|
|
134
|
+
</div>
|
|
135
|
+
`
|
|
136
|
+
: null
|
|
137
|
+
}
|
|
138
|
+
</div>
|
|
139
|
+
`;
|
|
140
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="en" class="dark">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>Legio</title>
|
|
7
|
+
<link rel="icon" href="/favicon.ico" sizes="any">
|
|
8
|
+
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16.png">
|
|
9
|
+
<link rel="icon" type="image/png" sizes="32x32" href="/favicon-32.png">
|
|
10
|
+
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
|
11
|
+
<script src="https://cdn.tailwindcss.com"></script>
|
|
12
|
+
<script>
|
|
13
|
+
tailwind.config = {
|
|
14
|
+
darkMode: 'class',
|
|
15
|
+
theme: {
|
|
16
|
+
extend: {
|
|
17
|
+
colors: {
|
|
18
|
+
surface: '#1a1a1a',
|
|
19
|
+
border: '#2a2a2a',
|
|
20
|
+
accent: '#E64415',
|
|
21
|
+
},
|
|
22
|
+
fontFamily: {
|
|
23
|
+
sans: ['system-ui', '-apple-system', 'sans-serif'],
|
|
24
|
+
mono: ['ui-monospace', 'SFMono-Regular', 'monospace'],
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
</script>
|
|
30
|
+
<script type="importmap">
|
|
31
|
+
{
|
|
32
|
+
"imports": {
|
|
33
|
+
"preact": "https://esm.sh/preact@10.25.4",
|
|
34
|
+
"preact/": "https://esm.sh/preact@10.25.4/",
|
|
35
|
+
"preact/hooks": "https://esm.sh/preact@10.25.4/hooks",
|
|
36
|
+
"@preact/signals": "https://esm.sh/@preact/signals@1.3.1",
|
|
37
|
+
"htm": "https://esm.sh/htm@3.1.1",
|
|
38
|
+
"htm/preact": "https://esm.sh/htm@3.1.1/preact",
|
|
39
|
+
"marked": "https://esm.sh/marked@15.0.7"
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
</script>
|
|
43
|
+
<style>
|
|
44
|
+
body { background: #0f0f0f; color: #e5e5e5; margin: 0; }
|
|
45
|
+
.chat-markdown p { margin: 0.25em 0; }
|
|
46
|
+
.chat-markdown h1 { font-weight: 600; font-size: 1.25em; margin: 0.5em 0 0.25em; }
|
|
47
|
+
.chat-markdown h2 { font-weight: 600; font-size: 1.1em; margin: 0.5em 0 0.25em; }
|
|
48
|
+
.chat-markdown h3 { font-weight: 600; font-size: 1em; margin: 0.5em 0 0.25em; }
|
|
49
|
+
.chat-markdown ul, .chat-markdown ol { padding-left: 1.5em; margin: 0.25em 0; }
|
|
50
|
+
.chat-markdown code { background: #2a2a2a; padding: 0.1em 0.3em; border-radius: 3px; font-size: 0.9em; }
|
|
51
|
+
.chat-markdown pre { background: #1a1a1a; border: 1px solid #2a2a2a; padding: 0.75em; border-radius: 4px; overflow-x: auto; margin: 0.5em 0; }
|
|
52
|
+
.chat-markdown pre code { background: none; padding: 0; border-radius: 0; }
|
|
53
|
+
.chat-markdown a { color: #60a5fa; text-decoration: underline; }
|
|
54
|
+
.chat-markdown blockquote { border-left: 3px solid #2a2a2a; padding-left: 0.75em; color: #999; margin: 0.5em 0; }
|
|
55
|
+
.chat-markdown table { border-collapse: collapse; margin: 0.5em 0; }
|
|
56
|
+
.chat-markdown th, .chat-markdown td { border: 1px solid #2a2a2a; padding: 0.25em 0.5em; }
|
|
57
|
+
.chat-markdown th { background: #1a1a1a; }
|
|
58
|
+
</style>
|
|
59
|
+
</head>
|
|
60
|
+
<body class="bg-[#0f0f0f] text-[#e5e5e5] min-h-screen">
|
|
61
|
+
<div id="app"></div>
|
|
62
|
+
<script type="module" src="/app.js"></script>
|
|
63
|
+
</body>
|
|
64
|
+
</html>
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// Legio Web UI — Fetch Helpers
|
|
2
|
+
// Thin wrappers around the Fetch API. No Preact dependencies.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Fetch a URL as JSON. Throws on non-OK HTTP status.
|
|
6
|
+
*/
|
|
7
|
+
export async function fetchJson(url) {
|
|
8
|
+
const res = await fetch(url);
|
|
9
|
+
if (!res.ok) {
|
|
10
|
+
throw new Error(`HTTP ${res.status} for ${url}`);
|
|
11
|
+
}
|
|
12
|
+
return res.json();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* POST JSON to a URL. Throws on non-OK HTTP status, including error body.
|
|
17
|
+
*/
|
|
18
|
+
export async function postJson(url, body) {
|
|
19
|
+
const res = await fetch(url, {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: { "Content-Type": "application/json" },
|
|
22
|
+
body: JSON.stringify(body),
|
|
23
|
+
});
|
|
24
|
+
if (!res.ok) {
|
|
25
|
+
let msg = `HTTP ${res.status}`;
|
|
26
|
+
try {
|
|
27
|
+
const err = await res.json();
|
|
28
|
+
if (err.error) msg = err.error;
|
|
29
|
+
} catch (_e) {
|
|
30
|
+
// ignore parse failure — use default message
|
|
31
|
+
}
|
|
32
|
+
throw new Error(msg);
|
|
33
|
+
}
|
|
34
|
+
return res.json();
|
|
35
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
// Legio Web UI — Preact setup barrel re-export.
|
|
2
|
+
// Re-exports Preact primitives for view components.
|
|
3
|
+
// Uses bare specifiers resolved by the importmap in index.html.
|
|
4
|
+
|
|
5
|
+
export { computed, signal } from "@preact/signals";
|
|
6
|
+
export { html } from "htm/preact";
|
|
7
|
+
export { h, render } from "preact";
|
|
8
|
+
export { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from "preact/hooks";
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
// Legio Web UI — Reactive State (Preact Signals)
|
|
2
|
+
// All UI state lives here as signals so components re-render automatically.
|
|
3
|
+
|
|
4
|
+
import { computed, signal } from "@preact/signals";
|
|
5
|
+
|
|
6
|
+
export const appState = {
|
|
7
|
+
agents: signal([]),
|
|
8
|
+
mail: signal([]),
|
|
9
|
+
mergeQueue: signal([]),
|
|
10
|
+
metrics: signal([]),
|
|
11
|
+
snapshots: signal([]),
|
|
12
|
+
runs: signal({ active: null, list: [] }),
|
|
13
|
+
events: signal([]),
|
|
14
|
+
errors: signal([]),
|
|
15
|
+
issues: signal([]),
|
|
16
|
+
audit: signal([]),
|
|
17
|
+
config: signal(null),
|
|
18
|
+
status: signal(null),
|
|
19
|
+
connected: signal(false),
|
|
20
|
+
lastUpdated: signal(null),
|
|
21
|
+
selectedAgent: signal(null),
|
|
22
|
+
inspectAgent: signal(null),
|
|
23
|
+
inspectData: signal(null),
|
|
24
|
+
selectedPair: signal(null),
|
|
25
|
+
collapsedThreads: signal(new Set()),
|
|
26
|
+
coordinator: signal(null),
|
|
27
|
+
pendingChatContext: signal(null),
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function setConnected(value) {
|
|
31
|
+
appState.connected.value = value;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function setLastUpdated() {
|
|
35
|
+
appState.lastUpdated.value = new Date().toISOString();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// Computed: agents that are working or booting
|
|
39
|
+
export const activeAgents = computed(() =>
|
|
40
|
+
appState.agents.value.filter((a) => a.state === "working" || a.state === "booting"),
|
|
41
|
+
);
|
|
42
|
+
|
|
43
|
+
// Computed: count of unread mail messages
|
|
44
|
+
export const unreadMailCount = computed(() => appState.mail.value.filter((m) => !m.readAt).length);
|
|
45
|
+
|
|
46
|
+
// Agent activity events detected from WebSocket snapshot diffs
|
|
47
|
+
export const agentActivityLog = signal([]);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Compare two agent arrays and append activity events to agentActivityLog.
|
|
51
|
+
* Detects: spawned (new agent), state_change, removed.
|
|
52
|
+
* Keeps a max of 200 entries (trims from front).
|
|
53
|
+
*/
|
|
54
|
+
export function recordAgentDiff(prevAgents, nextAgents) {
|
|
55
|
+
const prevMap = new Map((prevAgents ?? []).map((a) => [a.agentName ?? a.name, a]));
|
|
56
|
+
const nextMap = new Map((nextAgents ?? []).map((a) => [a.agentName ?? a.name, a]));
|
|
57
|
+
const events = [];
|
|
58
|
+
const timestamp = new Date().toISOString();
|
|
59
|
+
|
|
60
|
+
for (const [name, next] of nextMap) {
|
|
61
|
+
const prev = prevMap.get(name);
|
|
62
|
+
if (!prev) {
|
|
63
|
+
events.push({
|
|
64
|
+
type: "spawned",
|
|
65
|
+
agent: name,
|
|
66
|
+
capability: next.capability ?? null,
|
|
67
|
+
beadId: next.beadId ?? next.taskId ?? null,
|
|
68
|
+
timestamp,
|
|
69
|
+
});
|
|
70
|
+
} else if (prev.state !== next.state) {
|
|
71
|
+
events.push({
|
|
72
|
+
type: "state_change",
|
|
73
|
+
agent: name,
|
|
74
|
+
capability: next.capability ?? null,
|
|
75
|
+
from: prev.state,
|
|
76
|
+
to: next.state,
|
|
77
|
+
beadId: next.beadId ?? next.taskId ?? null,
|
|
78
|
+
timestamp,
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
for (const [name, prev] of prevMap) {
|
|
84
|
+
if (!nextMap.has(name)) {
|
|
85
|
+
events.push({
|
|
86
|
+
type: "removed",
|
|
87
|
+
agent: name,
|
|
88
|
+
capability: prev.capability ?? null,
|
|
89
|
+
beadId: prev.beadId ?? prev.taskId ?? null,
|
|
90
|
+
timestamp,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
if (events.length === 0) return;
|
|
96
|
+
|
|
97
|
+
const combined = [...agentActivityLog.value, ...events];
|
|
98
|
+
agentActivityLog.value = combined.length > 200 ? combined.slice(combined.length - 200) : combined;
|
|
99
|
+
}
|
|
@@ -0,0 +1,309 @@
|
|
|
1
|
+
// Legio Web UI — Utility Functions
|
|
2
|
+
// Pure functions with no external dependencies.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Format a duration in milliseconds to a human-readable string.
|
|
6
|
+
* e.g. 500 -> "< 1s", 5000 -> "5s", 90000 -> "1m 30s", 3700000 -> "1h 1m"
|
|
7
|
+
*/
|
|
8
|
+
export function formatDuration(ms) {
|
|
9
|
+
if (ms < 1000) return "< 1s";
|
|
10
|
+
const s = Math.floor(ms / 1000);
|
|
11
|
+
const m = Math.floor(s / 60);
|
|
12
|
+
const h = Math.floor(m / 60);
|
|
13
|
+
if (h > 0) return `${h}h ${m % 60}m`;
|
|
14
|
+
if (m > 0) return `${m}m ${s % 60}s`;
|
|
15
|
+
return `${s}s`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Return a human-readable relative time from an ISO date string.
|
|
20
|
+
* e.g. "2m ago", "1h ago", "3d ago"
|
|
21
|
+
*/
|
|
22
|
+
export function timeAgo(isoString) {
|
|
23
|
+
if (!isoString) return "";
|
|
24
|
+
const diff = Date.now() - new Date(isoString).getTime();
|
|
25
|
+
if (diff < 0) return "just now";
|
|
26
|
+
const s = Math.floor(diff / 1000);
|
|
27
|
+
if (s < 60) return `${s}s ago`;
|
|
28
|
+
const m = Math.floor(s / 60);
|
|
29
|
+
if (m < 60) return `${m}m ago`;
|
|
30
|
+
const h = Math.floor(m / 60);
|
|
31
|
+
if (h < 24) return `${h}h ago`;
|
|
32
|
+
const d = Math.floor(h / 24);
|
|
33
|
+
return `${d}d ago`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Truncate a string to maxLen characters, appending "..." if needed.
|
|
38
|
+
*/
|
|
39
|
+
export function truncate(str, maxLen) {
|
|
40
|
+
if (!str) return "";
|
|
41
|
+
if (str.length <= maxLen) return str;
|
|
42
|
+
return `${str.slice(0, maxLen - 3)}...`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Escape HTML special characters to prevent XSS in template literals.
|
|
47
|
+
* Always use this before inserting user-controlled content into innerHTML.
|
|
48
|
+
*/
|
|
49
|
+
export function escapeHtml(str) {
|
|
50
|
+
if (str == null) return "";
|
|
51
|
+
return String(str)
|
|
52
|
+
.replace(/&/g, "&")
|
|
53
|
+
.replace(/</g, "<")
|
|
54
|
+
.replace(/>/g, ">")
|
|
55
|
+
.replace(/"/g, """)
|
|
56
|
+
.replace(/'/g, "'");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Return a Unicode icon for an agent state.
|
|
61
|
+
*/
|
|
62
|
+
export function stateIcon(agentState) {
|
|
63
|
+
switch (agentState) {
|
|
64
|
+
case "working":
|
|
65
|
+
return "●";
|
|
66
|
+
case "booting":
|
|
67
|
+
return "◐";
|
|
68
|
+
case "stalled":
|
|
69
|
+
return "⚠";
|
|
70
|
+
case "zombie":
|
|
71
|
+
return "○";
|
|
72
|
+
case "completed":
|
|
73
|
+
return "✓";
|
|
74
|
+
default:
|
|
75
|
+
return "?";
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Return a Tailwind text color class for an agent state.
|
|
81
|
+
*/
|
|
82
|
+
export function stateColor(agentState) {
|
|
83
|
+
switch (agentState) {
|
|
84
|
+
case "working":
|
|
85
|
+
return "text-green-500";
|
|
86
|
+
case "booting":
|
|
87
|
+
return "text-yellow-500";
|
|
88
|
+
case "stalled":
|
|
89
|
+
return "text-red-500";
|
|
90
|
+
case "zombie":
|
|
91
|
+
return "text-gray-500";
|
|
92
|
+
case "completed":
|
|
93
|
+
return "text-blue-500";
|
|
94
|
+
default:
|
|
95
|
+
return "text-gray-500";
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Return a Tailwind text color class for a message priority.
|
|
101
|
+
*/
|
|
102
|
+
export function priorityColor(priority) {
|
|
103
|
+
switch (priority) {
|
|
104
|
+
case "urgent":
|
|
105
|
+
return "text-red-500";
|
|
106
|
+
case "high":
|
|
107
|
+
return "text-orange-400";
|
|
108
|
+
case "normal":
|
|
109
|
+
return "text-gray-300";
|
|
110
|
+
case "low":
|
|
111
|
+
return "text-gray-500";
|
|
112
|
+
default:
|
|
113
|
+
return "text-gray-300";
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ===== Agent Color Coding =====
|
|
118
|
+
// Each agent capability gets a distinct color applied to borders, badges, and dots.
|
|
119
|
+
// coordinator=blue, lead=green, builder=purple, scout=orange, reviewer=teal
|
|
120
|
+
|
|
121
|
+
const AGENT_COLORS = {
|
|
122
|
+
coordinator: {
|
|
123
|
+
bg: "bg-blue-500/10",
|
|
124
|
+
border: "border-blue-500",
|
|
125
|
+
text: "text-blue-400",
|
|
126
|
+
dot: "bg-blue-500",
|
|
127
|
+
avatar: "🎯",
|
|
128
|
+
},
|
|
129
|
+
lead: {
|
|
130
|
+
bg: "bg-green-500/10",
|
|
131
|
+
border: "border-green-500",
|
|
132
|
+
text: "text-green-400",
|
|
133
|
+
dot: "bg-green-500",
|
|
134
|
+
avatar: "⚡",
|
|
135
|
+
},
|
|
136
|
+
builder: {
|
|
137
|
+
bg: "bg-purple-500/10",
|
|
138
|
+
border: "border-purple-500",
|
|
139
|
+
text: "text-purple-400",
|
|
140
|
+
dot: "bg-purple-500",
|
|
141
|
+
avatar: "🔧",
|
|
142
|
+
},
|
|
143
|
+
scout: {
|
|
144
|
+
bg: "bg-orange-500/10",
|
|
145
|
+
border: "border-orange-500",
|
|
146
|
+
text: "text-orange-400",
|
|
147
|
+
dot: "bg-orange-500",
|
|
148
|
+
avatar: "🔍",
|
|
149
|
+
},
|
|
150
|
+
reviewer: {
|
|
151
|
+
bg: "bg-teal-500/10",
|
|
152
|
+
border: "border-teal-500",
|
|
153
|
+
text: "text-teal-400",
|
|
154
|
+
dot: "bg-teal-500",
|
|
155
|
+
avatar: "📝",
|
|
156
|
+
},
|
|
157
|
+
};
|
|
158
|
+
|
|
159
|
+
const DEFAULT_AGENT_COLOR = {
|
|
160
|
+
bg: "bg-gray-500/10",
|
|
161
|
+
border: "border-gray-500",
|
|
162
|
+
text: "text-gray-400",
|
|
163
|
+
dot: "bg-gray-500",
|
|
164
|
+
avatar: "💬",
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Return the color set for a given agent capability.
|
|
169
|
+
* Returns DEFAULT_AGENT_COLOR if capability is falsy or not recognized.
|
|
170
|
+
*/
|
|
171
|
+
export function agentColor(capability) {
|
|
172
|
+
if (!capability) return DEFAULT_AGENT_COLOR;
|
|
173
|
+
return AGENT_COLORS[capability.toLowerCase()] ?? DEFAULT_AGENT_COLOR;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Infer agent capability from agent name or agents array.
|
|
178
|
+
* Rules (in priority order):
|
|
179
|
+
* 1. "coordinator" or "orchestrator" → "coordinator"
|
|
180
|
+
* 2. Found in agents array → return agent.capability
|
|
181
|
+
* 3. Name pattern: -lead → "lead", -builder → "builder", -scout → "scout",
|
|
182
|
+
* review- prefix or -reviewer → "reviewer"
|
|
183
|
+
* 4. null if no match
|
|
184
|
+
*/
|
|
185
|
+
export function inferCapability(agentName, agents) {
|
|
186
|
+
if (!agentName) return null;
|
|
187
|
+
const lower = agentName.toLowerCase();
|
|
188
|
+
if (lower === "coordinator" || lower === "orchestrator") return "coordinator";
|
|
189
|
+
if (agents && agents.length > 0) {
|
|
190
|
+
const found = agents.find((a) => a.agentName === agentName || a.name === agentName);
|
|
191
|
+
if (found?.capability) return found.capability;
|
|
192
|
+
}
|
|
193
|
+
if (lower.endsWith("-lead")) return "lead";
|
|
194
|
+
if (lower.endsWith("-builder")) return "builder";
|
|
195
|
+
if (lower.endsWith("-scout")) return "scout";
|
|
196
|
+
if (lower.startsWith("review-") || lower.endsWith("-reviewer")) return "reviewer";
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Mail types that represent agent activity events (not conversational messages). */
|
|
201
|
+
export const ACTIVITY_MAIL_TYPES = new Set([
|
|
202
|
+
"dispatch",
|
|
203
|
+
"worker_done",
|
|
204
|
+
"merge_ready",
|
|
205
|
+
"merged",
|
|
206
|
+
"merge_failed",
|
|
207
|
+
"health_check",
|
|
208
|
+
"assign",
|
|
209
|
+
]);
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Returns true if the given message is an activity-type message.
|
|
213
|
+
*/
|
|
214
|
+
export function isActivityMessage(msg) {
|
|
215
|
+
return ACTIVITY_MAIL_TYPES.has(msg?.type);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Group consecutive same-type activity messages within 30s into group objects.
|
|
220
|
+
* Non-activity messages (_isAgentActivity, pending, conversational) pass through
|
|
221
|
+
* unchanged and break any active group.
|
|
222
|
+
* Single activity messages with no same-type neighbors within 30s pass through unchanged.
|
|
223
|
+
*
|
|
224
|
+
* @param {Array} messages - sorted oldest-first
|
|
225
|
+
* @returns {Array} Array of Message or GroupedActivity objects
|
|
226
|
+
*/
|
|
227
|
+
export function groupActivityMessages(messages) {
|
|
228
|
+
const result = [];
|
|
229
|
+
let i = 0;
|
|
230
|
+
|
|
231
|
+
while (i < messages.length) {
|
|
232
|
+
const msg = messages[i];
|
|
233
|
+
|
|
234
|
+
// Non-groupable: _isAgentActivity, pending (has status field), or not an activity type
|
|
235
|
+
const isGroupable = isActivityMessage(msg) && !msg._isAgentActivity && msg.status == null;
|
|
236
|
+
|
|
237
|
+
if (!isGroupable) {
|
|
238
|
+
result.push(msg);
|
|
239
|
+
i++;
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Scan forward for consecutive same-type activity messages within 30s each
|
|
244
|
+
const type = msg.type;
|
|
245
|
+
let j = i + 1;
|
|
246
|
+
|
|
247
|
+
while (j < messages.length) {
|
|
248
|
+
const next = messages[j];
|
|
249
|
+
const nextGroupable =
|
|
250
|
+
isActivityMessage(next) && !next._isAgentActivity && next.status == null;
|
|
251
|
+
if (!nextGroupable) break;
|
|
252
|
+
if (next.type !== type) break;
|
|
253
|
+
const timeDiff =
|
|
254
|
+
new Date(next.createdAt).getTime() - new Date(messages[j - 1].createdAt).getTime();
|
|
255
|
+
if (timeDiff > 30000) break;
|
|
256
|
+
j++;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
const group = messages.slice(i, j);
|
|
260
|
+
|
|
261
|
+
if (group.length === 1) {
|
|
262
|
+
// Solo activity message — pass through unchanged
|
|
263
|
+
result.push(msg);
|
|
264
|
+
} else {
|
|
265
|
+
const firstTimestamp = group[0].createdAt;
|
|
266
|
+
const lastTimestamp = group[group.length - 1].createdAt;
|
|
267
|
+
result.push({
|
|
268
|
+
_isGroup: true,
|
|
269
|
+
type,
|
|
270
|
+
children: group,
|
|
271
|
+
count: group.length,
|
|
272
|
+
firstTimestamp,
|
|
273
|
+
lastTimestamp,
|
|
274
|
+
id: `group-${type}-${firstTimestamp}`,
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
i = j;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
return result;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* Generate a human-readable summary label for a grouped activity.
|
|
286
|
+
* @param {{ count: number, type: string }} group
|
|
287
|
+
* @returns {string}
|
|
288
|
+
*/
|
|
289
|
+
export function groupSummaryLabel(group) {
|
|
290
|
+
const { count, type } = group;
|
|
291
|
+
switch (type) {
|
|
292
|
+
case "dispatch":
|
|
293
|
+
return `${count} agents dispatched`;
|
|
294
|
+
case "worker_done":
|
|
295
|
+
return `${count} agents completed`;
|
|
296
|
+
case "merge_ready":
|
|
297
|
+
return `${count} branches ready to merge`;
|
|
298
|
+
case "merged":
|
|
299
|
+
return `${count} branches merged`;
|
|
300
|
+
case "merge_failed":
|
|
301
|
+
return `${count} merges failed`;
|
|
302
|
+
case "health_check":
|
|
303
|
+
return `${count} health checks`;
|
|
304
|
+
case "assign":
|
|
305
|
+
return `${count} tasks assigned`;
|
|
306
|
+
default:
|
|
307
|
+
return `${count} ${type} events`;
|
|
308
|
+
}
|
|
309
|
+
}
|