@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.
Files changed (219) hide show
  1. package/CHANGELOG.md +422 -0
  2. package/LICENSE +21 -0
  3. package/README.md +555 -0
  4. package/agents/builder.md +141 -0
  5. package/agents/coordinator.md +351 -0
  6. package/agents/cto.md +196 -0
  7. package/agents/gateway.md +276 -0
  8. package/agents/lead.md +281 -0
  9. package/agents/merger.md +156 -0
  10. package/agents/monitor.md +212 -0
  11. package/agents/reviewer.md +142 -0
  12. package/agents/scout.md +131 -0
  13. package/agents/supervisor.md +416 -0
  14. package/bin/legio.mjs +38 -0
  15. package/package.json +77 -0
  16. package/src/agents/checkpoint.test.ts +88 -0
  17. package/src/agents/checkpoint.ts +102 -0
  18. package/src/agents/hooks-deployer.test.ts +1820 -0
  19. package/src/agents/hooks-deployer.ts +574 -0
  20. package/src/agents/identity.test.ts +614 -0
  21. package/src/agents/identity.ts +385 -0
  22. package/src/agents/lifecycle.test.ts +202 -0
  23. package/src/agents/lifecycle.ts +184 -0
  24. package/src/agents/manifest.test.ts +558 -0
  25. package/src/agents/manifest.ts +297 -0
  26. package/src/agents/overlay.test.ts +592 -0
  27. package/src/agents/overlay.ts +316 -0
  28. package/src/beads/client.test.ts +210 -0
  29. package/src/beads/client.ts +227 -0
  30. package/src/beads/molecules.test.ts +320 -0
  31. package/src/beads/molecules.ts +209 -0
  32. package/src/commands/agents.test.ts +325 -0
  33. package/src/commands/agents.ts +286 -0
  34. package/src/commands/clean.test.ts +730 -0
  35. package/src/commands/clean.ts +653 -0
  36. package/src/commands/completions.test.ts +346 -0
  37. package/src/commands/completions.ts +950 -0
  38. package/src/commands/coordinator.test.ts +1524 -0
  39. package/src/commands/coordinator.ts +880 -0
  40. package/src/commands/costs.test.ts +1015 -0
  41. package/src/commands/costs.ts +473 -0
  42. package/src/commands/dashboard.test.ts +94 -0
  43. package/src/commands/dashboard.ts +607 -0
  44. package/src/commands/doctor.test.ts +295 -0
  45. package/src/commands/doctor.ts +213 -0
  46. package/src/commands/down.test.ts +308 -0
  47. package/src/commands/down.ts +124 -0
  48. package/src/commands/errors.test.ts +648 -0
  49. package/src/commands/errors.ts +255 -0
  50. package/src/commands/feed.test.ts +579 -0
  51. package/src/commands/feed.ts +368 -0
  52. package/src/commands/gateway.test.ts +698 -0
  53. package/src/commands/gateway.ts +419 -0
  54. package/src/commands/group.test.ts +262 -0
  55. package/src/commands/group.ts +539 -0
  56. package/src/commands/hooks.test.ts +292 -0
  57. package/src/commands/hooks.ts +210 -0
  58. package/src/commands/init.test.ts +211 -0
  59. package/src/commands/init.ts +622 -0
  60. package/src/commands/inspect.test.ts +670 -0
  61. package/src/commands/inspect.ts +455 -0
  62. package/src/commands/log.test.ts +1556 -0
  63. package/src/commands/log.ts +752 -0
  64. package/src/commands/logs.test.ts +379 -0
  65. package/src/commands/logs.ts +544 -0
  66. package/src/commands/mail.test.ts +1726 -0
  67. package/src/commands/mail.ts +926 -0
  68. package/src/commands/merge.test.ts +676 -0
  69. package/src/commands/merge.ts +374 -0
  70. package/src/commands/metrics.test.ts +444 -0
  71. package/src/commands/metrics.ts +150 -0
  72. package/src/commands/monitor.test.ts +151 -0
  73. package/src/commands/monitor.ts +394 -0
  74. package/src/commands/nudge.test.ts +230 -0
  75. package/src/commands/nudge.ts +373 -0
  76. package/src/commands/prime.test.ts +467 -0
  77. package/src/commands/prime.ts +386 -0
  78. package/src/commands/replay.test.ts +742 -0
  79. package/src/commands/replay.ts +367 -0
  80. package/src/commands/run.test.ts +443 -0
  81. package/src/commands/run.ts +365 -0
  82. package/src/commands/server.test.ts +626 -0
  83. package/src/commands/server.ts +298 -0
  84. package/src/commands/sling.test.ts +810 -0
  85. package/src/commands/sling.ts +700 -0
  86. package/src/commands/spec.test.ts +206 -0
  87. package/src/commands/spec.ts +171 -0
  88. package/src/commands/status.test.ts +276 -0
  89. package/src/commands/status.ts +339 -0
  90. package/src/commands/stop.test.ts +357 -0
  91. package/src/commands/stop.ts +119 -0
  92. package/src/commands/supervisor.test.ts +186 -0
  93. package/src/commands/supervisor.ts +544 -0
  94. package/src/commands/trace.test.ts +746 -0
  95. package/src/commands/trace.ts +332 -0
  96. package/src/commands/up.test.ts +597 -0
  97. package/src/commands/up.ts +275 -0
  98. package/src/commands/watch.test.ts +152 -0
  99. package/src/commands/watch.ts +238 -0
  100. package/src/commands/worktree.test.ts +648 -0
  101. package/src/commands/worktree.ts +266 -0
  102. package/src/config.test.ts +496 -0
  103. package/src/config.ts +616 -0
  104. package/src/doctor/agents.test.ts +448 -0
  105. package/src/doctor/agents.ts +396 -0
  106. package/src/doctor/config-check.test.ts +184 -0
  107. package/src/doctor/config-check.ts +185 -0
  108. package/src/doctor/consistency.test.ts +645 -0
  109. package/src/doctor/consistency.ts +294 -0
  110. package/src/doctor/databases.test.ts +284 -0
  111. package/src/doctor/databases.ts +211 -0
  112. package/src/doctor/dependencies.test.ts +150 -0
  113. package/src/doctor/dependencies.ts +179 -0
  114. package/src/doctor/logs.test.ts +244 -0
  115. package/src/doctor/logs.ts +295 -0
  116. package/src/doctor/merge-queue.test.ts +210 -0
  117. package/src/doctor/merge-queue.ts +144 -0
  118. package/src/doctor/structure.test.ts +285 -0
  119. package/src/doctor/structure.ts +195 -0
  120. package/src/doctor/types.ts +37 -0
  121. package/src/doctor/version.test.ts +130 -0
  122. package/src/doctor/version.ts +131 -0
  123. package/src/e2e/chat-flow.test.ts +346 -0
  124. package/src/e2e/init-sling-lifecycle.test.ts +288 -0
  125. package/src/errors.test.ts +21 -0
  126. package/src/errors.ts +246 -0
  127. package/src/events/store.test.ts +660 -0
  128. package/src/events/store.ts +344 -0
  129. package/src/events/tool-filter.test.ts +330 -0
  130. package/src/events/tool-filter.ts +126 -0
  131. package/src/global-setup.ts +14 -0
  132. package/src/index.ts +339 -0
  133. package/src/insights/analyzer.test.ts +466 -0
  134. package/src/insights/analyzer.ts +203 -0
  135. package/src/logging/color.test.ts +118 -0
  136. package/src/logging/color.ts +71 -0
  137. package/src/logging/logger.test.ts +812 -0
  138. package/src/logging/logger.ts +266 -0
  139. package/src/logging/reporter.test.ts +258 -0
  140. package/src/logging/reporter.ts +109 -0
  141. package/src/logging/sanitizer.test.ts +190 -0
  142. package/src/logging/sanitizer.ts +57 -0
  143. package/src/mail/broadcast.test.ts +203 -0
  144. package/src/mail/broadcast.ts +92 -0
  145. package/src/mail/client.test.ts +873 -0
  146. package/src/mail/client.ts +236 -0
  147. package/src/mail/store.test.ts +815 -0
  148. package/src/mail/store.ts +402 -0
  149. package/src/merge/queue.test.ts +449 -0
  150. package/src/merge/queue.ts +262 -0
  151. package/src/merge/resolver.test.ts +1453 -0
  152. package/src/merge/resolver.ts +759 -0
  153. package/src/metrics/store.test.ts +1167 -0
  154. package/src/metrics/store.ts +511 -0
  155. package/src/metrics/summary.test.ts +397 -0
  156. package/src/metrics/summary.ts +178 -0
  157. package/src/metrics/transcript.test.ts +643 -0
  158. package/src/metrics/transcript.ts +351 -0
  159. package/src/mulch/client.test.ts +547 -0
  160. package/src/mulch/client.ts +416 -0
  161. package/src/server/audit-store.test.ts +384 -0
  162. package/src/server/audit-store.ts +257 -0
  163. package/src/server/headless.test.ts +180 -0
  164. package/src/server/headless.ts +151 -0
  165. package/src/server/index.test.ts +241 -0
  166. package/src/server/index.ts +317 -0
  167. package/src/server/public/app.js +187 -0
  168. package/src/server/public/apple-touch-icon.png +0 -0
  169. package/src/server/public/components/agent-badge.js +37 -0
  170. package/src/server/public/components/data-table.js +114 -0
  171. package/src/server/public/components/gateway-chat.js +256 -0
  172. package/src/server/public/components/issue-card.js +96 -0
  173. package/src/server/public/components/layout.js +88 -0
  174. package/src/server/public/components/message-bubble.js +120 -0
  175. package/src/server/public/components/stat-card.js +26 -0
  176. package/src/server/public/components/terminal-panel.js +140 -0
  177. package/src/server/public/favicon-16.png +0 -0
  178. package/src/server/public/favicon-32.png +0 -0
  179. package/src/server/public/favicon.ico +0 -0
  180. package/src/server/public/favicon.png +0 -0
  181. package/src/server/public/index.html +64 -0
  182. package/src/server/public/lib/api.js +35 -0
  183. package/src/server/public/lib/markdown.js +8 -0
  184. package/src/server/public/lib/preact-setup.js +8 -0
  185. package/src/server/public/lib/state.js +99 -0
  186. package/src/server/public/lib/utils.js +309 -0
  187. package/src/server/public/lib/ws.js +79 -0
  188. package/src/server/public/views/chat.js +983 -0
  189. package/src/server/public/views/costs.js +692 -0
  190. package/src/server/public/views/dashboard.js +781 -0
  191. package/src/server/public/views/gateway-chat.js +622 -0
  192. package/src/server/public/views/inspect.js +399 -0
  193. package/src/server/public/views/issues.js +470 -0
  194. package/src/server/public/views/setup.js +94 -0
  195. package/src/server/public/views/task-detail.js +422 -0
  196. package/src/server/routes.test.ts +3816 -0
  197. package/src/server/routes.ts +1964 -0
  198. package/src/server/websocket.test.ts +288 -0
  199. package/src/server/websocket.ts +196 -0
  200. package/src/sessions/compat.test.ts +109 -0
  201. package/src/sessions/compat.ts +17 -0
  202. package/src/sessions/store.test.ts +969 -0
  203. package/src/sessions/store.ts +480 -0
  204. package/src/test-helpers.test.ts +97 -0
  205. package/src/test-helpers.ts +143 -0
  206. package/src/types.ts +708 -0
  207. package/src/watchdog/daemon.test.ts +1233 -0
  208. package/src/watchdog/daemon.ts +533 -0
  209. package/src/watchdog/health.test.ts +371 -0
  210. package/src/watchdog/health.ts +248 -0
  211. package/src/watchdog/triage.test.ts +162 -0
  212. package/src/watchdog/triage.ts +193 -0
  213. package/src/worktree/manager.test.ts +444 -0
  214. package/src/worktree/manager.ts +224 -0
  215. package/src/worktree/tmux.test.ts +1238 -0
  216. package/src/worktree/tmux.ts +644 -0
  217. package/templates/CLAUDE.md.tmpl +89 -0
  218. package/templates/hooks.json.tmpl +132 -0
  219. 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
+ import { marked } from "marked";
2
+
3
+ marked.setOptions({ breaks: true, gfm: true });
4
+
5
+ export function renderMarkdown(text) {
6
+ if (!text) return "";
7
+ return marked.parse(text);
8
+ }
@@ -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, "&amp;")
53
+ .replace(/</g, "&lt;")
54
+ .replace(/>/g, "&gt;")
55
+ .replace(/"/g, "&quot;")
56
+ .replace(/'/g, "&#39;");
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
+ }