@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,781 @@
1
+ // dashboard.js — Unified Dashboard View (Preact+HTM)
2
+ // Two-panel: Left = Coordinator Chat (~58%), Right = Sidebar (MetricsStrip + AgentRoster + MailFeed)
3
+ // Merges former CommandView and DashboardView into a single unified page.
4
+
5
+ import { fetchJson, postJson } from "../lib/api.js";
6
+ import { html, useCallback, useEffect, useState } from "../lib/preact-setup.js";
7
+ import { appState } from "../lib/state.js";
8
+ import { agentColor, stateColor, stateIcon, timeAgo } from "../lib/utils.js";
9
+ import { GatewayChat } from "./gateway-chat.js";
10
+
11
+ // ---------------------------------------------------------------------------
12
+ // Constants
13
+ // ---------------------------------------------------------------------------
14
+
15
+ // Type badge Tailwind classes for ActivityTimeline
16
+ const TYPE_COLORS = {
17
+ session_start: "bg-green-900/50 text-green-400",
18
+ session_end: "bg-[#333] text-[#999]",
19
+ mail_sent: "bg-purple-900/50 text-purple-400",
20
+ mail_received: "bg-purple-900/50 text-purple-400",
21
+ mail: "bg-purple-900/50 text-purple-400",
22
+ error: "bg-red-900/50 text-red-400",
23
+ system: "bg-[#333] text-[#999]",
24
+ };
25
+
26
+ // Type badge Tailwind classes for MailFeed
27
+ const MAIL_TYPE_COLORS = {
28
+ result: "bg-green-900/50 text-green-400",
29
+ worker_done: "bg-green-900/50 text-green-400",
30
+ merged: "bg-green-900/50 text-green-400",
31
+ status: "bg-blue-900/50 text-blue-400",
32
+ dispatch: "bg-blue-900/50 text-blue-400",
33
+ assign: "bg-blue-900/50 text-blue-400",
34
+ question: "bg-yellow-900/50 text-yellow-400",
35
+ merge_ready: "bg-yellow-900/50 text-yellow-400",
36
+ error: "bg-red-900/50 text-red-400",
37
+ merge_failed: "bg-red-900/50 text-red-400",
38
+ escalation: "bg-red-900/50 text-red-400",
39
+ health_check: "bg-[#333] text-[#999]",
40
+ };
41
+
42
+ // ---------------------------------------------------------------------------
43
+ // Helpers
44
+ // ---------------------------------------------------------------------------
45
+
46
+ function buildEventSummary(e) {
47
+ switch (e.eventType) {
48
+ case "session_start":
49
+ return `${e.agentName} session started`;
50
+ case "session_end":
51
+ return `${e.agentName} session ended`;
52
+ case "mail_sent":
53
+ return `Mail sent by ${e.agentName}`;
54
+ case "mail_received":
55
+ return `Mail received by ${e.agentName}`;
56
+ case "error": {
57
+ try {
58
+ return `Error: ${JSON.parse(e.data || "{}").message || "unknown"}`;
59
+ } catch {
60
+ return "Error";
61
+ }
62
+ }
63
+ default:
64
+ return e.eventType;
65
+ }
66
+ }
67
+
68
+ function typeBadgeClass(type) {
69
+ return TYPE_COLORS[type] ?? "bg-[#333] text-[#999]";
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // MetricsStrip
74
+ // ---------------------------------------------------------------------------
75
+
76
+ function MetricsStrip({ agents, status }) {
77
+ const totalSessions = agents.length;
78
+ const activeCount = agents.filter((a) => a.state === "working" || a.state === "booting").length;
79
+ const completedCount = agents.filter((a) => a.state === "completed").length;
80
+ const unreadMail = status?.unreadMailCount ?? 0;
81
+ const pendingMerges = status?.mergeQueueCount ?? 0;
82
+
83
+ const stats = [
84
+ { label: "Sessions", value: totalSessions },
85
+ { label: "Active", value: activeCount },
86
+ { label: "Completed", value: completedCount },
87
+ { label: "Unread Mail", value: unreadMail },
88
+ { label: "Pending Merges", value: pendingMerges },
89
+ ];
90
+
91
+ return html`
92
+ <div class="bg-[#1a1a1a] border-b border-[#2a2a2a] shrink-0">
93
+ <div class="border-b border-[#2a2a2a] px-3 py-1.5 text-xs font-bold uppercase tracking-wider text-gray-400">
94
+ Metrics
95
+ </div>
96
+ <div class="flex flex-wrap gap-4 px-3 py-2">
97
+ ${stats.map(
98
+ ({ label, value }) => html`
99
+ <span key=${label} class="text-xs text-gray-400">
100
+ ${label}:${" "}<strong class="text-white">${value}</strong>
101
+ </span>
102
+ `,
103
+ )}
104
+ </div>
105
+ </div>
106
+ `;
107
+ }
108
+
109
+ // ---------------------------------------------------------------------------
110
+ // MailFeed
111
+ // ---------------------------------------------------------------------------
112
+
113
+ function MailFeed({ mail }) {
114
+ const [activeFilters, setActiveFilters] = useState(new Set());
115
+ const [expandedId, setExpandedId] = useState(null);
116
+
117
+ const sorted = [...mail]
118
+ .filter((m) => m.audience !== "human")
119
+ .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
120
+ .slice(0, 50);
121
+
122
+ const filtered =
123
+ activeFilters.size === 0 ? sorted : sorted.filter((m) => activeFilters.has(m.type));
124
+
125
+ const toggleExpand = useCallback((id) => {
126
+ setExpandedId((prev) => (prev === id ? null : id));
127
+ }, []);
128
+
129
+ const allTypes = Object.keys(MAIL_TYPE_COLORS);
130
+
131
+ const toggleFilter = useCallback((type) => {
132
+ setActiveFilters((prev) => {
133
+ const next = new Set(prev);
134
+ if (next.has(type)) {
135
+ next.delete(type);
136
+ } else {
137
+ next.add(type);
138
+ }
139
+ return next;
140
+ });
141
+ }, []);
142
+
143
+ const clearFilters = useCallback(() => {
144
+ setActiveFilters(new Set());
145
+ }, []);
146
+
147
+ return html`
148
+ <div class="bg-[#1a1a1a] border-t border-[#2a2a2a] shrink-0">
149
+ <div class="border-b border-[#2a2a2a] px-3 py-1.5 text-xs font-bold uppercase tracking-wider text-gray-400">
150
+ Mail Feed
151
+ </div>
152
+ <!-- Filter chips -->
153
+ <div class="flex flex-wrap gap-1 px-2 py-1.5 border-b border-[#2a2a2a]">
154
+ <button
155
+ onClick=${clearFilters}
156
+ class=${`px-1.5 py-0.5 rounded text-xs font-mono cursor-pointer border-none ${activeFilters.size === 0 ? "bg-white/20 text-white" : "bg-[#2a2a2a] text-[#666]"}`}
157
+ >
158
+ All
159
+ </button>
160
+ ${allTypes.map(
161
+ (type) => html`
162
+ <button
163
+ key=${type}
164
+ onClick=${() => toggleFilter(type)}
165
+ class=${`px-1.5 py-0.5 rounded text-xs font-mono cursor-pointer border-none ${activeFilters.has(type) ? (MAIL_TYPE_COLORS[type] ?? "bg-[#333] text-[#999]") : "bg-[#2a2a2a] text-[#666]"}`}
166
+ >
167
+ ${type}
168
+ </button>
169
+ `,
170
+ )}
171
+ </div>
172
+ <div class="overflow-y-auto max-h-[30vh] p-2 space-y-0.5">
173
+ ${
174
+ filtered.length === 0
175
+ ? html`<div class="px-2 py-4 text-center text-gray-500 text-xs">No recent mail</div>`
176
+ : filtered.map((m) => {
177
+ const isExpanded = expandedId === m.id;
178
+ let parsedPayload = null;
179
+ if (m.payload) {
180
+ try {
181
+ parsedPayload =
182
+ typeof m.payload === "string" ? JSON.parse(m.payload) : m.payload;
183
+ } catch {
184
+ parsedPayload = m.payload;
185
+ }
186
+ }
187
+ return html`
188
+ <div
189
+ key=${m.id}
190
+ class="rounded-sm ${isExpanded ? "bg-white/5 border border-[#2a2a2a]" : "border border-transparent"}"
191
+ >
192
+ <div
193
+ class="flex items-center gap-1.5 px-2 py-1 text-xs cursor-pointer hover:bg-white/5 rounded-sm"
194
+ onClick=${() => toggleExpand(m.id)}
195
+ >
196
+ <span
197
+ class=${`px-1 rounded font-mono flex-shrink-0 ${MAIL_TYPE_COLORS[m.type] ?? "bg-[#333] text-[#999]"}`}
198
+ >
199
+ ${m.type || "mail"}
200
+ </span>
201
+ <span class="text-[#777] flex-shrink-0 truncate max-w-[5rem]">${m.from}</span>
202
+ <span class="text-[#444] flex-shrink-0">\u2192</span>
203
+ <span class="text-[#777] flex-shrink-0 truncate max-w-[5rem]">${m.to}</span>
204
+ <span class="flex-1 truncate text-[#999] min-w-0">${m.subject || ""}</span>
205
+ <span class="text-[#444] flex-shrink-0 ml-auto">${timeAgo(m.createdAt)}</span>
206
+ <span class="text-[#444] flex-shrink-0 ml-1">${isExpanded ? "\u25B2" : "\u25BC"}</span>
207
+ </div>
208
+ ${
209
+ isExpanded
210
+ ? html`
211
+ <div class="px-2 pb-2 text-xs border-t border-[#2a2a2a] mt-0.5 pt-1.5 space-y-1">
212
+ ${
213
+ m.priority && m.priority !== "normal"
214
+ ? html`
215
+ <div class="flex gap-1.5">
216
+ <span class="text-[#555] flex-shrink-0">priority:</span>
217
+ <span class="text-yellow-400 font-mono">${m.priority}</span>
218
+ </div>
219
+ `
220
+ : null
221
+ }
222
+ ${
223
+ m.body
224
+ ? html`
225
+ <div>
226
+ <div class="text-[#555] mb-0.5">body:</div>
227
+ <div class="text-[#aaa] whitespace-pre-wrap break-words font-mono bg-[#111] rounded px-2 py-1 max-h-[10rem] overflow-y-auto">
228
+ ${m.body}
229
+ </div>
230
+ </div>
231
+ `
232
+ : null
233
+ }
234
+ ${
235
+ parsedPayload
236
+ ? html`
237
+ <div>
238
+ <div class="text-[#555] mb-0.5">payload:</div>
239
+ <div class="text-[#aaa] whitespace-pre-wrap break-words font-mono bg-[#111] rounded px-2 py-1 max-h-[10rem] overflow-y-auto">
240
+ ${JSON.stringify(parsedPayload, null, 2)}
241
+ </div>
242
+ </div>
243
+ `
244
+ : null
245
+ }
246
+ </div>
247
+ `
248
+ : null
249
+ }
250
+ </div>
251
+ `;
252
+ })
253
+ }
254
+ </div>
255
+ </div>
256
+ `;
257
+ }
258
+
259
+ // ---------------------------------------------------------------------------
260
+ // AgentRoster
261
+ // ---------------------------------------------------------------------------
262
+
263
+ const STATE_ORDER = { working: 0, booting: 1, stalled: 2, zombie: 3, completed: 4 };
264
+
265
+ /**
266
+ * Build a depth-annotated ordered list from a flat agents array.
267
+ * Agents whose parentAgent is absent or not in the list are roots (depth 0).
268
+ * Children are placed immediately after their parent, sorted by state.
269
+ */
270
+ function buildAgentHierarchy(agents) {
271
+ const agentNames = new Set(agents.map((a) => a.agentName));
272
+ const byParent = new Map();
273
+ const roots = [];
274
+
275
+ for (const agent of agents) {
276
+ const parent = agent.parentAgent;
277
+ if (!parent || !agentNames.has(parent)) {
278
+ roots.push(agent);
279
+ } else {
280
+ if (!byParent.has(parent)) byParent.set(parent, []);
281
+ byParent.get(parent).push(agent);
282
+ }
283
+ }
284
+
285
+ function sortByState(arr) {
286
+ return [...arr].sort((a, b) => {
287
+ const ao = STATE_ORDER[a.state] ?? 99;
288
+ const bo = STATE_ORDER[b.state] ?? 99;
289
+ if (ao !== bo) return ao - bo;
290
+ return (a.agentName ?? "").localeCompare(b.agentName ?? "");
291
+ });
292
+ }
293
+
294
+ const result = [];
295
+ function walk(agent, depth) {
296
+ result.push({ agent, depth });
297
+ const children = sortByState(byParent.get(agent.agentName) ?? []);
298
+ for (const child of children) {
299
+ walk(child, depth + 1);
300
+ }
301
+ }
302
+ for (const root of sortByState(roots)) {
303
+ walk(root, 0);
304
+ }
305
+ return result;
306
+ }
307
+
308
+ function AgentRoster({ agents, mail, events }) {
309
+ const [expandedAgent, setExpandedAgent] = useState(null);
310
+
311
+ const ordered = buildAgentHierarchy(agents);
312
+
313
+ const activeCount = agents.filter((a) => a.state === "working" || a.state === "booting").length;
314
+
315
+ const toggleExpand = useCallback((name) => {
316
+ setExpandedAgent((prev) => (prev === name ? null : name));
317
+ }, []);
318
+
319
+ return html`
320
+ <div class="flex flex-col flex-1 min-h-0">
321
+ <!-- Header -->
322
+ <div class="px-3 py-2 border-b border-[#2a2a2a] shrink-0">
323
+ <span class="text-sm font-medium text-[#e5e5e5]">Agents</span>
324
+ <span class="ml-2 text-xs text-[#555]">${activeCount} active / ${agents.length} total</span>
325
+ </div>
326
+
327
+ <!-- Agent list -->
328
+ <div class="flex-1 overflow-y-auto min-h-0 p-2">
329
+ ${
330
+ ordered.length === 0
331
+ ? html`
332
+ <div class="flex items-center justify-center h-full text-[#666] text-sm">
333
+ No agents yet
334
+ </div>
335
+ `
336
+ : ordered.map(({ agent, depth }) => {
337
+ const isExpanded = expandedAgent === agent.agentName;
338
+ const colors = agentColor(agent.capability);
339
+ const icon = stateIcon(agent.state);
340
+ const iconColor = stateColor(agent.state);
341
+
342
+ // Filter mail for this agent
343
+ const agentMail = mail
344
+ .filter((m) => m.from === agent.agentName || m.to === agent.agentName)
345
+ .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime())
346
+ .slice(0, 5);
347
+
348
+ // Filter events for this agent
349
+ const agentEvents = events.filter((e) => e.agent === agent.agentName).slice(0, 5);
350
+
351
+ return html`
352
+ <div
353
+ key=${agent.agentName}
354
+ class="mb-1 rounded border border-[#2a2a2a] bg-[#1a1a1a] overflow-hidden"
355
+ style=${{ marginLeft: `${depth * 16}px` }}
356
+ >
357
+ <!-- Row -->
358
+ <div
359
+ class="flex items-center gap-2 px-3 py-2 cursor-pointer hover:border-[#3a3a3a] hover:bg-[#222]"
360
+ onClick=${() => toggleExpand(agent.agentName)}
361
+ >
362
+ <span class="text-base leading-none flex-shrink-0">${colors.avatar}</span>
363
+ <span class=${`text-xs leading-none flex-shrink-0 ${iconColor}`}>${icon}</span>
364
+ <span class="text-sm text-[#e5e5e5] truncate flex-1 min-w-0">
365
+ ${agent.agentName}
366
+ </span>
367
+ ${
368
+ agent.beadId
369
+ ? html`<span class="text-xs font-mono text-[#666] flex-shrink-0">
370
+ ${agent.beadId}
371
+ </span>`
372
+ : null
373
+ }
374
+ <a
375
+ href=${`#inspect/${agent.agentName}`}
376
+ class="text-xs text-blue-400 hover:text-blue-300 flex-shrink-0 px-1.5 py-0.5 rounded bg-blue-900/20 hover:bg-blue-900/40 no-underline"
377
+ onClick=${(e) => e.stopPropagation()}
378
+ >
379
+ Details
380
+ </a>
381
+ <span class="text-xs text-[#444] flex-shrink-0">
382
+ ${isExpanded ? "\u25B2" : "\u25BC"}
383
+ </span>
384
+ </div>
385
+
386
+ <!-- Expanded detail -->
387
+ ${
388
+ isExpanded
389
+ ? html`
390
+ <div class="border-t border-[#2a2a2a] px-3 py-2 text-xs">
391
+ <!-- Meta badges -->
392
+ <div class="flex flex-wrap gap-1.5 mb-2">
393
+ <span
394
+ class=${`px-1.5 py-0.5 rounded font-mono ${colors.bg} ${colors.text} border ${colors.border}`}
395
+ >
396
+ ${agent.capability || "unknown"}
397
+ </span>
398
+ <span
399
+ class=${`px-1.5 py-0.5 rounded font-mono ${iconColor}`}
400
+ >
401
+ ${icon} ${agent.state}
402
+ </span>
403
+ ${
404
+ agent.beadId
405
+ ? html`<span class="font-mono text-[#666]">${agent.beadId}</span>`
406
+ : null
407
+ }
408
+ ${
409
+ agent.startedAt
410
+ ? html`<span class="text-[#555]">started ${timeAgo(agent.startedAt)}</span>`
411
+ : null
412
+ }
413
+ </div>
414
+
415
+ <!-- Drill-down link -->
416
+ <div class="mb-2">
417
+ <a
418
+ href=${`#inspect/${agent.agentName}`}
419
+ class="text-xs text-blue-400 hover:text-blue-300"
420
+ >
421
+ View Details
422
+ </a>
423
+ </div>
424
+
425
+ <!-- Recent Mail -->
426
+ <div class="mb-2">
427
+ <div class="text-[#555] mb-1">Recent Mail</div>
428
+ ${
429
+ agentMail.length === 0
430
+ ? null
431
+ : agentMail.map(
432
+ (m) => html`
433
+ <div
434
+ key=${m.id}
435
+ class="flex items-center gap-1.5 py-0.5 border-b border-[#1f1f1f]"
436
+ >
437
+ <span class="text-[#444] flex-shrink-0">
438
+ ${m.from === agent.agentName ? "\u2192" : "\u2190"}
439
+ </span>
440
+ <span class="text-[#666] flex-shrink-0 truncate max-w-[6rem]">
441
+ ${m.from === agent.agentName ? m.to : m.from}
442
+ </span>
443
+ <span class="flex-1 truncate text-[#999] min-w-0">
444
+ ${m.subject || m.body || ""}
445
+ </span>
446
+ <span class="text-[#444] flex-shrink-0 ml-auto">
447
+ ${timeAgo(m.createdAt)}
448
+ </span>
449
+ </div>
450
+ `,
451
+ )
452
+ }
453
+ </div>
454
+
455
+ <!-- Recent Events -->
456
+ <div>
457
+ <div class="text-[#555] mb-1">Recent Events</div>
458
+ ${
459
+ agentEvents.length === 0
460
+ ? null
461
+ : agentEvents.map(
462
+ (ev) => html`
463
+ <div
464
+ key=${ev.id}
465
+ class="flex items-center gap-1.5 py-0.5 border-b border-[#1f1f1f]"
466
+ >
467
+ <span
468
+ class=${`px-1 rounded font-mono flex-shrink-0 ${typeBadgeClass(ev.type)}`}
469
+ >
470
+ ${ev.type || "unknown"}
471
+ </span>
472
+ <span class="flex-1 truncate text-[#999] min-w-0">
473
+ ${ev.summary || ""}
474
+ </span>
475
+ <span class="text-[#444] flex-shrink-0 ml-auto">
476
+ ${timeAgo(ev.createdAt)}
477
+ </span>
478
+ </div>
479
+ `,
480
+ )
481
+ }
482
+ </div>
483
+
484
+ ${
485
+ agentMail.length === 0 && agentEvents.length === 0
486
+ ? html`<div class="text-[#444] text-center py-1">No recent activity</div>`
487
+ : null
488
+ }
489
+ </div>
490
+ `
491
+ : null
492
+ }
493
+ </div>
494
+ `;
495
+ })
496
+ }
497
+ </div>
498
+ </div>
499
+ `;
500
+ }
501
+
502
+ // ---------------------------------------------------------------------------
503
+ // CoordinatorBar
504
+ // ---------------------------------------------------------------------------
505
+
506
+ function CoordinatorBar() {
507
+ const [coordStatus, setCoordStatus] = useState(null); // null = unknown, true = running, false = stopped
508
+ const [loading, setLoading] = useState(false); // start/stop in-flight
509
+ const [gwStatus, setGwStatus] = useState(null); // null = unknown, true = running, false = stopped
510
+ const [gwLoading, setGwLoading] = useState(false);
511
+ const [error, setError] = useState(null);
512
+
513
+ const poll = useCallback(async (cancelled) => {
514
+ try {
515
+ const data = await fetchJson("/api/coordinator/status");
516
+ if (!cancelled) setCoordStatus(data?.running === true);
517
+ } catch (_err) {
518
+ // non-fatal — leave status as unknown
519
+ }
520
+ try {
521
+ const gwData = await fetchJson("/api/gateway/status");
522
+ if (!cancelled) setGwStatus(gwData?.running === true);
523
+ } catch (_err) {
524
+ /* non-fatal */
525
+ }
526
+ }, []);
527
+
528
+ useEffect(() => {
529
+ let cancelled = false;
530
+ poll(cancelled);
531
+ const interval = setInterval(() => poll(cancelled), 5000);
532
+ return () => {
533
+ cancelled = true;
534
+ clearInterval(interval);
535
+ };
536
+ }, [poll]);
537
+
538
+ // Auto-clear error after 5s
539
+ useEffect(() => {
540
+ if (!error) return;
541
+ const timer = setTimeout(() => setError(null), 5000);
542
+ return () => clearTimeout(timer);
543
+ }, [error]);
544
+
545
+ const handleStart = useCallback(async () => {
546
+ setLoading(true);
547
+ setError(null);
548
+ try {
549
+ await postJson("/api/coordinator/start", {});
550
+ await poll(false);
551
+ } catch (err) {
552
+ setError(err?.message ?? "Failed to start coordinator");
553
+ } finally {
554
+ setLoading(false);
555
+ }
556
+ }, [poll]);
557
+
558
+ const handleStop = useCallback(async () => {
559
+ setLoading(true);
560
+ setError(null);
561
+ try {
562
+ await postJson("/api/coordinator/stop", {});
563
+ await poll(false);
564
+ } catch (err) {
565
+ setError(err?.message ?? "Failed to stop coordinator");
566
+ } finally {
567
+ setLoading(false);
568
+ }
569
+ }, [poll]);
570
+
571
+ const handleGwStart = useCallback(async () => {
572
+ setGwLoading(true);
573
+ setError(null);
574
+ try {
575
+ await postJson("/api/gateway/start", {});
576
+ await poll(false);
577
+ } catch (err) {
578
+ setError(err?.message ?? "Failed to start gateway");
579
+ } finally {
580
+ setGwLoading(false);
581
+ }
582
+ }, [poll]);
583
+
584
+ const handleGwStop = useCallback(async () => {
585
+ setGwLoading(true);
586
+ setError(null);
587
+ try {
588
+ await postJson("/api/gateway/stop", {});
589
+ await poll(false);
590
+ } catch (err) {
591
+ setError(err?.message ?? "Failed to stop gateway");
592
+ } finally {
593
+ setGwLoading(false);
594
+ }
595
+ }, [poll]);
596
+
597
+ const isRunning = coordStatus === true;
598
+ const isStopped = coordStatus === false;
599
+ const isUnknown = coordStatus === null;
600
+
601
+ const dotColor = isRunning ? "bg-green-500" : isStopped ? "bg-[#666]" : "bg-[#666]";
602
+ const statusText = isRunning ? "Running" : isStopped ? "Stopped" : "Unknown";
603
+
604
+ const gwIsRunning = gwStatus === true;
605
+ const gwIsStopped = gwStatus === false;
606
+ const gwIsUnknown = gwStatus === null;
607
+
608
+ const gwDotColor = gwIsRunning ? "bg-green-500" : "bg-[#666]";
609
+ const gwStatusText = gwIsRunning ? "Running" : gwIsStopped ? "Stopped" : "Unknown";
610
+
611
+ return html`
612
+ <div class="flex items-center gap-3 px-3 py-2 bg-[#1a1a1a] border-b border-[#2a2a2a] shrink-0">
613
+ <div class="flex items-center gap-2">
614
+ <span class="text-xs text-[#666] uppercase tracking-wide">Coordinator</span>
615
+ <div class="flex items-center gap-1">
616
+ <div class="w-2 h-2 rounded-full ${dotColor}"></div>
617
+ <span class="text-sm text-[#e5e5e5]">${statusText}</span>
618
+ </div>
619
+ </div>
620
+ <div class="flex items-center gap-2">
621
+ <button
622
+ onClick=${handleStart}
623
+ disabled=${loading || isRunning}
624
+ class="bg-[#E64415] hover:bg-[#cc3d12] disabled:opacity-50 text-white text-sm px-3 py-1 rounded cursor-pointer border-none"
625
+ >
626
+ ${loading && !isRunning ? "\u2026" : "Start"}
627
+ </button>
628
+ <button
629
+ onClick=${handleStop}
630
+ disabled=${loading || isStopped || isUnknown}
631
+ class="bg-[#E64415] hover:bg-[#cc3d12] disabled:opacity-50 text-white text-sm px-3 py-1 rounded cursor-pointer border-none"
632
+ >
633
+ ${loading && isRunning ? "\u2026" : "Stop"}
634
+ </button>
635
+ </div>
636
+ <div class="border-l border-[#2a2a2a] pl-3 ml-1 flex items-center gap-2">
637
+ <span class="text-xs text-[#666] uppercase tracking-wide">Gateway</span>
638
+ <div class="flex items-center gap-1">
639
+ <div class="w-2 h-2 rounded-full ${gwDotColor}"></div>
640
+ <span class="text-sm text-[#e5e5e5]">${gwStatusText}</span>
641
+ </div>
642
+ </div>
643
+ <div class="flex items-center gap-2">
644
+ <button
645
+ onClick=${handleGwStart}
646
+ disabled=${gwLoading || gwIsRunning}
647
+ class="bg-[#E64415] hover:bg-[#cc3d12] disabled:opacity-50 text-white text-sm px-3 py-1 rounded cursor-pointer border-none"
648
+ >
649
+ ${gwLoading && !gwIsRunning ? "\u2026" : "Start"}
650
+ </button>
651
+ <button
652
+ onClick=${handleGwStop}
653
+ disabled=${gwLoading || gwIsStopped || gwIsUnknown}
654
+ class="bg-[#E64415] hover:bg-[#cc3d12] disabled:opacity-50 text-white text-sm px-3 py-1 rounded cursor-pointer border-none"
655
+ >
656
+ ${gwLoading && gwIsRunning ? "\u2026" : "Stop"}
657
+ </button>
658
+ </div>
659
+ ${error ? html`<span class="text-xs text-red-400">${error}</span>` : null}
660
+ </div>
661
+ `;
662
+ }
663
+
664
+ // ---------------------------------------------------------------------------
665
+ // DashboardView — main export
666
+ // ---------------------------------------------------------------------------
667
+
668
+ const NOISE_EVENT_TYPES = new Set(["tool_start", "tool_end"]);
669
+
670
+ export function DashboardView() {
671
+ const [activityEvents, setActivityEvents] = useState([]);
672
+ const [mail, setMail] = useState([]);
673
+ const [_coordRunning, setCoordRunning] = useState(false);
674
+ const [gwRunning, setGwRunning] = useState(false);
675
+
676
+ // Poll event store every 5s
677
+ useEffect(() => {
678
+ let cancelled = false;
679
+
680
+ async function fetchActivity() {
681
+ try {
682
+ const since = new Date(Date.now() - 24 * 60 * 60 * 1000).toISOString();
683
+ const data = await fetchJson(`/api/events?since=${encodeURIComponent(since)}&limit=200`);
684
+ if (!cancelled) {
685
+ const rawEvents = Array.isArray(data) ? data : (data?.events ?? []);
686
+ const filteredEvents = rawEvents
687
+ .filter((e) => !NOISE_EVENT_TYPES.has(e.eventType))
688
+ .map((e) => ({
689
+ id: `evt-${e.id}`,
690
+ type: e.eventType,
691
+ agent: e.agentName,
692
+ summary: buildEventSummary(e),
693
+ detail: e.data,
694
+ createdAt: e.createdAt,
695
+ }));
696
+ setActivityEvents(filteredEvents);
697
+ }
698
+ } catch (_err) {
699
+ // non-fatal
700
+ }
701
+ }
702
+
703
+ fetchActivity();
704
+ const interval = setInterval(fetchActivity, 5000);
705
+ return () => {
706
+ cancelled = true;
707
+ clearInterval(interval);
708
+ };
709
+ }, []);
710
+
711
+ // Poll mail every 5s
712
+ useEffect(() => {
713
+ let cancelled = false;
714
+ async function fetchMail() {
715
+ try {
716
+ const data = await fetchJson("/api/mail");
717
+ if (!cancelled) {
718
+ setMail(Array.isArray(data) ? data : (data?.recent ?? []));
719
+ }
720
+ } catch (_err) {
721
+ // non-fatal
722
+ }
723
+ }
724
+ fetchMail();
725
+ const interval = setInterval(fetchMail, 5000);
726
+ return () => {
727
+ cancelled = true;
728
+ clearInterval(interval);
729
+ };
730
+ }, []);
731
+
732
+ // Poll coordinator/gateway status for chat routing
733
+ useEffect(() => {
734
+ let cancelled = false;
735
+ async function fetchStatuses() {
736
+ try {
737
+ const [coordData, gwData] = await Promise.all([
738
+ fetchJson("/api/coordinator/status").catch(() => null),
739
+ fetchJson("/api/gateway/status").catch(() => null),
740
+ ]);
741
+ if (!cancelled) {
742
+ setCoordRunning(coordData?.running === true);
743
+ setGwRunning(gwData?.running === true);
744
+ }
745
+ } catch (_err) {
746
+ // non-fatal
747
+ }
748
+ }
749
+ fetchStatuses();
750
+ const interval = setInterval(fetchStatuses, 5000);
751
+ return () => {
752
+ cancelled = true;
753
+ clearInterval(interval);
754
+ };
755
+ }, []);
756
+
757
+ const agents = appState.agents.value;
758
+ const status = appState.status.value;
759
+
760
+ return html`
761
+ <div class="flex flex-col h-full bg-[#0f0f0f] min-h-0">
762
+ <${CoordinatorBar} />
763
+ <div class="flex flex-1 min-h-0">
764
+ <!-- Coordinator Chat (left, ~58%) -->
765
+ <div
766
+ class="flex flex-col min-h-0 overflow-hidden border-r border-[#2a2a2a]"
767
+ style="flex: 58 1 0%"
768
+ >
769
+ <${GatewayChat} gwRunning=${gwRunning} />
770
+ </div>
771
+
772
+ <!-- Sidebar (right, ~42%): MetricsStrip + AgentRoster + MailFeed -->
773
+ <div class="flex flex-col min-h-0 overflow-hidden" style="flex: 42 1 0%">
774
+ <${MetricsStrip} agents=${agents} status=${status} />
775
+ <${AgentRoster} agents=${agents} mail=${mail} events=${activityEvents} />
776
+ <${MailFeed} mail=${mail} />
777
+ </div>
778
+ </div>
779
+ </div>
780
+ `;
781
+ }