@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,607 @@
1
+ /**
2
+ * CLI command: legio dashboard [--interval <ms>]
3
+ *
4
+ * Rich terminal dashboard using raw ANSI escape codes (zero runtime deps).
5
+ * Polls existing data sources and renders multi-panel layout with agent status,
6
+ * mail activity, merge queue, and metrics.
7
+ */
8
+
9
+ import { access } from "node:fs/promises";
10
+ import { join } from "node:path";
11
+ import { loadConfig } from "../config.ts";
12
+ import { ValidationError } from "../errors.ts";
13
+ import { color } from "../logging/color.ts";
14
+ import { createMailStore } from "../mail/store.ts";
15
+ import { createMergeQueue } from "../merge/queue.ts";
16
+ import { createMetricsStore } from "../metrics/store.ts";
17
+ import type { MailMessage } from "../types.ts";
18
+ import { gatherStatus, type StatusData } from "./status.ts";
19
+
20
+ /**
21
+ * Terminal control codes (cursor movement, screen clearing).
22
+ * These are not colors, so they stay separate from the color module.
23
+ */
24
+ const CURSOR = {
25
+ clear: "\x1b[2J\x1b[H", // Clear screen and home cursor
26
+ cursorTo: (row: number, col: number) => `\x1b[${row};${col}H`,
27
+ hideCursor: "\x1b[?25l",
28
+ showCursor: "\x1b[?25h",
29
+ } as const;
30
+
31
+ /**
32
+ * Box drawing characters for panel borders.
33
+ */
34
+ const BOX = {
35
+ topLeft: "┌",
36
+ topRight: "┐",
37
+ bottomLeft: "└",
38
+ bottomRight: "┘",
39
+ horizontal: "─",
40
+ vertical: "│",
41
+ tee: "├",
42
+ teeRight: "┤",
43
+ cross: "┼",
44
+ };
45
+
46
+ /**
47
+ * Parse a named flag value from args.
48
+ */
49
+ function getFlag(args: string[], flag: string): string | undefined {
50
+ const idx = args.indexOf(flag);
51
+ if (idx === -1 || idx + 1 >= args.length) {
52
+ return undefined;
53
+ }
54
+ return args[idx + 1];
55
+ }
56
+
57
+ /**
58
+ * Format a duration in ms to a human-readable string.
59
+ */
60
+ function formatDuration(ms: number): string {
61
+ const seconds = Math.floor(ms / 1000);
62
+ if (seconds < 60) return `${seconds}s`;
63
+ const minutes = Math.floor(seconds / 60);
64
+ const remainSec = seconds % 60;
65
+ if (minutes < 60) return `${minutes}m ${remainSec}s`;
66
+ const hours = Math.floor(minutes / 60);
67
+ const remainMin = minutes % 60;
68
+ return `${hours}h ${remainMin}m`;
69
+ }
70
+
71
+ /**
72
+ * Format a timestamp to "time ago" format.
73
+ */
74
+ function timeAgo(timestamp: string): string {
75
+ const now = Date.now();
76
+ const then = new Date(timestamp).getTime();
77
+ const diffMs = now - then;
78
+ const diffSec = Math.floor(diffMs / 1000);
79
+
80
+ if (diffSec < 60) return `${diffSec}s ago`;
81
+ const diffMin = Math.floor(diffSec / 60);
82
+ if (diffMin < 60) return `${diffMin}m ago`;
83
+ const diffHr = Math.floor(diffMin / 60);
84
+ if (diffHr < 24) return `${diffHr}h ago`;
85
+ const diffDay = Math.floor(diffHr / 24);
86
+ return `${diffDay}d ago`;
87
+ }
88
+
89
+ /**
90
+ * Truncate a string to fit within maxLen characters, adding ellipsis if needed.
91
+ */
92
+ function truncate(str: string, maxLen: number): string {
93
+ if (str.length <= maxLen) return str;
94
+ return `${str.slice(0, maxLen - 1)}…`;
95
+ }
96
+
97
+ /**
98
+ * Pad or truncate a string to exactly the given width.
99
+ */
100
+ function pad(str: string, width: number): string {
101
+ if (str.length >= width) return str.slice(0, width);
102
+ return str + " ".repeat(width - str.length);
103
+ }
104
+
105
+ /**
106
+ * Draw a horizontal line with left/right/middle connectors.
107
+ */
108
+ function horizontalLine(width: number, left: string, _middle: string, right: string): string {
109
+ return left + BOX.horizontal.repeat(width - 2) + right;
110
+ }
111
+
112
+ export interface DashboardData {
113
+ status: StatusData;
114
+ recentMail: MailMessage[];
115
+ mergeQueue: Array<{ branchName: string; agentName: string; status: string }>;
116
+ metrics: {
117
+ totalSessions: number;
118
+ avgDuration: number;
119
+ byCapability: Record<string, number>;
120
+ };
121
+ }
122
+
123
+ /**
124
+ * Load all data sources for the dashboard.
125
+ */
126
+ export async function loadDashboardData(root: string): Promise<DashboardData> {
127
+ const status = await gatherStatus(root, "orchestrator", false);
128
+
129
+ // Load recent mail
130
+ let recentMail: MailMessage[] = [];
131
+ try {
132
+ const mailDbPath = join(root, ".legio", "mail.db");
133
+ let mailDbExists = false;
134
+ try {
135
+ await access(mailDbPath);
136
+ mailDbExists = true;
137
+ } catch {
138
+ /* not found */
139
+ }
140
+ if (mailDbExists) {
141
+ const mailStore = createMailStore(mailDbPath);
142
+ recentMail = mailStore.getAll().slice(0, 5);
143
+ mailStore.close();
144
+ }
145
+ } catch {
146
+ // Mail db might not exist
147
+ }
148
+
149
+ // Load merge queue
150
+ let mergeQueue: Array<{ branchName: string; agentName: string; status: string }> = [];
151
+ try {
152
+ const queuePath = join(root, ".legio", "merge-queue.db");
153
+ const queue = createMergeQueue(queuePath);
154
+ mergeQueue = queue.list().map((e) => ({
155
+ branchName: e.branchName,
156
+ agentName: e.agentName,
157
+ status: e.status,
158
+ }));
159
+ queue.close();
160
+ } catch {
161
+ // Queue db might not exist
162
+ }
163
+
164
+ // Load metrics
165
+ let totalSessions = 0;
166
+ let avgDuration = 0;
167
+ const byCapability: Record<string, number> = {};
168
+ try {
169
+ const metricsDbPath = join(root, ".legio", "metrics.db");
170
+ let metricsDbExists = false;
171
+ try {
172
+ await access(metricsDbPath);
173
+ metricsDbExists = true;
174
+ } catch {
175
+ /* not found */
176
+ }
177
+ if (metricsDbExists) {
178
+ const store = createMetricsStore(metricsDbPath);
179
+ const sessions = store.getRecentSessions(100);
180
+ totalSessions = sessions.length;
181
+ avgDuration = store.getAverageDuration();
182
+
183
+ // Count by capability
184
+ for (const session of sessions) {
185
+ const cap = session.capability;
186
+ byCapability[cap] = (byCapability[cap] ?? 0) + 1;
187
+ }
188
+
189
+ store.close();
190
+ }
191
+ } catch {
192
+ // Metrics db might not exist
193
+ }
194
+
195
+ return {
196
+ status,
197
+ recentMail,
198
+ mergeQueue,
199
+ metrics: { totalSessions, avgDuration, byCapability },
200
+ };
201
+ }
202
+
203
+ /**
204
+ * Render the header bar (line 1).
205
+ */
206
+ function renderHeader(width: number, interval: number): string {
207
+ const left = `${color.bold}legio dashboard v0.2.0${color.reset}`;
208
+ const now = new Date().toLocaleTimeString();
209
+ const right = `${now} | refresh: ${interval}ms`;
210
+ const leftStripped = "legio dashboard v0.2.0"; // for length calculation
211
+ const padding = width - leftStripped.length - right.length;
212
+ const line = left + " ".repeat(Math.max(0, padding)) + right;
213
+ const separator = horizontalLine(width, BOX.topLeft, BOX.horizontal, BOX.topRight);
214
+ return `${line}\n${separator}`;
215
+ }
216
+
217
+ /**
218
+ * Get color for agent state.
219
+ */
220
+ function getStateColor(state: string): string {
221
+ switch (state) {
222
+ case "working":
223
+ return color.green;
224
+ case "booting":
225
+ return color.yellow;
226
+ case "zombie":
227
+ return color.dim;
228
+ case "completed":
229
+ return color.cyan;
230
+ default:
231
+ return color.white;
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Get status icon for agent state.
237
+ */
238
+ function getStateIcon(state: string): string {
239
+ switch (state) {
240
+ case "working":
241
+ return "●";
242
+ case "booting":
243
+ return "◐";
244
+ case "zombie":
245
+ return "○";
246
+ case "completed":
247
+ return "✓";
248
+ default:
249
+ return "?";
250
+ }
251
+ }
252
+
253
+ /**
254
+ * Render the agent panel (top ~40% of screen).
255
+ */
256
+ function renderAgentPanel(
257
+ data: DashboardData,
258
+ width: number,
259
+ height: number,
260
+ startRow: number,
261
+ ): string {
262
+ const panelHeight = Math.floor(height * 0.4);
263
+ let output = "";
264
+
265
+ // Panel header
266
+ const headerLine = `${BOX.vertical} ${color.bold}Agents${color.reset} (${data.status.agents.length})`;
267
+ const headerPadding = " ".repeat(
268
+ width - headerLine.length - 1 + color.bold.length + color.reset.length,
269
+ );
270
+ output += `${CURSOR.cursorTo(startRow, 1)}${headerLine}${headerPadding}${BOX.vertical}\n`;
271
+
272
+ // Column headers
273
+ const colHeaders = `${BOX.vertical} St Name Capability State Bead ID Duration Tmux ${BOX.vertical}`;
274
+ output += `${CURSOR.cursorTo(startRow + 1, 1)}${colHeaders}\n`;
275
+
276
+ // Separator
277
+ const separator = horizontalLine(width, BOX.tee, BOX.horizontal, BOX.teeRight);
278
+ output += `${CURSOR.cursorTo(startRow + 2, 1)}${separator}\n`;
279
+
280
+ // Sort agents: active first (working, booting), then completed, then zombie
281
+ const agents = [...data.status.agents].sort((a, b) => {
282
+ const activeStates = ["working", "booting"];
283
+ const aActive = activeStates.includes(a.state);
284
+ const bActive = activeStates.includes(b.state);
285
+ if (aActive && !bActive) return -1;
286
+ if (!aActive && bActive) return 1;
287
+ return 0;
288
+ });
289
+
290
+ const now = Date.now();
291
+ const maxRows = panelHeight - 4; // header + col headers + separator + border
292
+ const visibleAgents = agents.slice(0, maxRows);
293
+
294
+ for (let i = 0; i < visibleAgents.length; i++) {
295
+ const agent = visibleAgents[i];
296
+ if (!agent) continue;
297
+
298
+ const icon = getStateIcon(agent.state);
299
+ const stateColor = getStateColor(agent.state);
300
+ const name = pad(truncate(agent.agentName, 15), 15);
301
+ const capability = pad(truncate(agent.capability, 12), 12);
302
+ const state = pad(agent.state, 10);
303
+ const beadId = pad(truncate(agent.beadId, 16), 16);
304
+ const endTime =
305
+ agent.state === "completed" || agent.state === "zombie"
306
+ ? new Date(agent.lastActivity).getTime()
307
+ : now;
308
+ const duration = formatDuration(endTime - new Date(agent.startedAt).getTime());
309
+ const durationPadded = pad(duration, 9);
310
+ const tmuxAlive = data.status.tmuxSessions.some((s) => s.name === agent.tmuxSession);
311
+ const tmuxDot = tmuxAlive ? `${color.green}●${color.reset}` : `${color.red}○${color.reset}`;
312
+
313
+ const line = `${BOX.vertical} ${stateColor}${icon}${color.reset} ${name} ${capability} ${stateColor}${state}${color.reset} ${beadId} ${durationPadded} ${tmuxDot} ${BOX.vertical}`;
314
+ output += `${CURSOR.cursorTo(startRow + 3 + i, 1)}${line}\n`;
315
+ }
316
+
317
+ // Fill remaining rows with empty lines
318
+ for (let i = visibleAgents.length; i < maxRows; i++) {
319
+ const emptyLine = `${BOX.vertical}${" ".repeat(width - 2)}${BOX.vertical}`;
320
+ output += `${CURSOR.cursorTo(startRow + 3 + i, 1)}${emptyLine}\n`;
321
+ }
322
+
323
+ // Bottom border
324
+ const bottomBorder = horizontalLine(width, BOX.tee, BOX.horizontal, BOX.teeRight);
325
+ output += `${CURSOR.cursorTo(startRow + 3 + maxRows, 1)}${bottomBorder}\n`;
326
+
327
+ return output;
328
+ }
329
+
330
+ /**
331
+ * Get color for mail priority.
332
+ */
333
+ function getPriorityColor(priority: string): string {
334
+ switch (priority) {
335
+ case "urgent":
336
+ return color.red;
337
+ case "high":
338
+ return color.yellow;
339
+ case "normal":
340
+ return color.white;
341
+ case "low":
342
+ return color.dim;
343
+ default:
344
+ return color.white;
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Render the mail panel (middle-left ~30% height, ~60% width).
350
+ */
351
+ function renderMailPanel(
352
+ data: DashboardData,
353
+ width: number,
354
+ height: number,
355
+ startRow: number,
356
+ ): string {
357
+ const panelHeight = Math.floor(height * 0.3);
358
+ const panelWidth = Math.floor(width * 0.6);
359
+ let output = "";
360
+
361
+ const unreadCount = data.status.unreadMailCount;
362
+ const headerLine = `${BOX.vertical} ${color.bold}Mail${color.reset} (${unreadCount} unread)`;
363
+ const headerPadding = " ".repeat(
364
+ panelWidth - headerLine.length - 1 + color.bold.length + color.reset.length,
365
+ );
366
+ output += `${CURSOR.cursorTo(startRow, 1)}${headerLine}${headerPadding}${BOX.vertical}\n`;
367
+
368
+ const separator = horizontalLine(panelWidth, BOX.tee, BOX.horizontal, BOX.cross);
369
+ output += `${CURSOR.cursorTo(startRow + 1, 1)}${separator}\n`;
370
+
371
+ const maxRows = panelHeight - 3; // header + separator + border
372
+ const messages = data.recentMail.slice(0, maxRows);
373
+
374
+ for (let i = 0; i < messages.length; i++) {
375
+ const msg = messages[i];
376
+ if (!msg) continue;
377
+
378
+ const priorityColor = getPriorityColor(msg.priority);
379
+ const priority = msg.priority === "normal" ? "" : `[${msg.priority}] `;
380
+ const from = truncate(msg.from, 12);
381
+ const to = truncate(msg.to, 12);
382
+ const subject = truncate(msg.subject, panelWidth - 40);
383
+ const time = timeAgo(msg.createdAt);
384
+
385
+ const line = `${BOX.vertical} ${priorityColor}${priority}${color.reset}${from} → ${to}: ${subject} (${time})`;
386
+ const padding = " ".repeat(
387
+ Math.max(
388
+ 0,
389
+ panelWidth -
390
+ line.length -
391
+ 1 +
392
+ priorityColor.length +
393
+ color.reset.length +
394
+ priorityColor.length +
395
+ color.reset.length,
396
+ ),
397
+ );
398
+ output += `${CURSOR.cursorTo(startRow + 2 + i, 1)}${line}${padding}${BOX.vertical}\n`;
399
+ }
400
+
401
+ // Fill remaining rows with empty lines
402
+ for (let i = messages.length; i < maxRows; i++) {
403
+ const emptyLine = `${BOX.vertical}${" ".repeat(panelWidth - 2)}${BOX.vertical}`;
404
+ output += `${CURSOR.cursorTo(startRow + 2 + i, 1)}${emptyLine}\n`;
405
+ }
406
+
407
+ return output;
408
+ }
409
+
410
+ /**
411
+ * Get color for merge queue status.
412
+ */
413
+ function getMergeStatusColor(status: string): string {
414
+ switch (status) {
415
+ case "pending":
416
+ return color.yellow;
417
+ case "merging":
418
+ return color.blue;
419
+ case "conflict":
420
+ return color.red;
421
+ case "merged":
422
+ return color.green;
423
+ default:
424
+ return color.white;
425
+ }
426
+ }
427
+
428
+ /**
429
+ * Render the merge queue panel (middle-right ~30% height, ~40% width).
430
+ */
431
+ function renderMergeQueuePanel(
432
+ data: DashboardData,
433
+ width: number,
434
+ height: number,
435
+ startRow: number,
436
+ startCol: number,
437
+ ): string {
438
+ const panelHeight = Math.floor(height * 0.3);
439
+ const panelWidth = width - startCol + 1;
440
+ let output = "";
441
+
442
+ const headerLine = `${BOX.vertical} ${color.bold}Merge Queue${color.reset} (${data.mergeQueue.length})`;
443
+ const headerPadding = " ".repeat(
444
+ panelWidth - headerLine.length - 1 + color.bold.length + color.reset.length,
445
+ );
446
+ output += `${CURSOR.cursorTo(startRow, startCol)}${headerLine}${headerPadding}${BOX.vertical}\n`;
447
+
448
+ const separator = horizontalLine(panelWidth, BOX.cross, BOX.horizontal, BOX.teeRight);
449
+ output += `${CURSOR.cursorTo(startRow + 1, startCol)}${separator}\n`;
450
+
451
+ const maxRows = panelHeight - 3; // header + separator + border
452
+ const entries = data.mergeQueue.slice(0, maxRows);
453
+
454
+ for (let i = 0; i < entries.length; i++) {
455
+ const entry = entries[i];
456
+ if (!entry) continue;
457
+
458
+ const statusColor = getMergeStatusColor(entry.status);
459
+ const status = pad(entry.status, 10);
460
+ const agent = truncate(entry.agentName, 15);
461
+ const branch = truncate(entry.branchName, panelWidth - 30);
462
+
463
+ const line = `${BOX.vertical} ${statusColor}${status}${color.reset} ${agent} ${branch}`;
464
+ const padding = " ".repeat(
465
+ Math.max(0, panelWidth - line.length - 1 + statusColor.length + color.reset.length),
466
+ );
467
+ output += `${CURSOR.cursorTo(startRow + 2 + i, startCol)}${line}${padding}${BOX.vertical}\n`;
468
+ }
469
+
470
+ // Fill remaining rows with empty lines
471
+ for (let i = entries.length; i < maxRows; i++) {
472
+ const emptyLine = `${BOX.vertical}${" ".repeat(panelWidth - 2)}${BOX.vertical}`;
473
+ output += `${CURSOR.cursorTo(startRow + 2 + i, startCol)}${emptyLine}\n`;
474
+ }
475
+
476
+ return output;
477
+ }
478
+
479
+ /**
480
+ * Render the metrics panel (bottom strip).
481
+ */
482
+ function renderMetricsPanel(
483
+ data: DashboardData,
484
+ width: number,
485
+ _height: number,
486
+ startRow: number,
487
+ ): string {
488
+ let output = "";
489
+
490
+ const separator = horizontalLine(width, BOX.tee, BOX.horizontal, BOX.teeRight);
491
+ output += `${CURSOR.cursorTo(startRow, 1)}${separator}\n`;
492
+
493
+ const headerLine = `${BOX.vertical} ${color.bold}Metrics${color.reset}`;
494
+ const headerPadding = " ".repeat(
495
+ width - headerLine.length - 1 + color.bold.length + color.reset.length,
496
+ );
497
+ output += `${CURSOR.cursorTo(startRow + 1, 1)}${headerLine}${headerPadding}${BOX.vertical}\n`;
498
+
499
+ const totalSessions = data.metrics.totalSessions;
500
+ const avgDuration = formatDuration(data.metrics.avgDuration);
501
+ const byCapability = Object.entries(data.metrics.byCapability)
502
+ .map(([cap, count]) => `${cap}:${count}`)
503
+ .join(", ");
504
+
505
+ const metricsLine = `${BOX.vertical} Total sessions: ${totalSessions} | Avg duration: ${avgDuration} | By capability: ${byCapability}`;
506
+ const metricsPadding = " ".repeat(Math.max(0, width - metricsLine.length - 1));
507
+ output += `${CURSOR.cursorTo(startRow + 2, 1)}${metricsLine}${metricsPadding}${BOX.vertical}\n`;
508
+
509
+ const bottomBorder = horizontalLine(width, BOX.bottomLeft, BOX.horizontal, BOX.bottomRight);
510
+ output += `${CURSOR.cursorTo(startRow + 3, 1)}${bottomBorder}\n`;
511
+
512
+ return output;
513
+ }
514
+
515
+ /**
516
+ * Render the full dashboard.
517
+ */
518
+ function renderDashboard(data: DashboardData, interval: number): void {
519
+ const width = process.stdout.columns ?? 100;
520
+ const height = process.stdout.rows ?? 30;
521
+
522
+ let output = CURSOR.clear;
523
+
524
+ // Header (rows 1-2)
525
+ output += renderHeader(width, interval);
526
+
527
+ // Agent panel (rows 3 to ~40% of screen)
528
+ const agentPanelStart = 3;
529
+ output += renderAgentPanel(data, width, height, agentPanelStart);
530
+
531
+ // Calculate middle panels start row
532
+ const agentPanelHeight = Math.floor(height * 0.4);
533
+ const middlePanelStart = agentPanelStart + agentPanelHeight + 1;
534
+
535
+ // Mail panel (left 60%)
536
+ output += renderMailPanel(data, width, height, middlePanelStart);
537
+
538
+ // Merge queue panel (right 40%)
539
+ const mergeQueueCol = Math.floor(width * 0.6) + 1;
540
+ output += renderMergeQueuePanel(data, width, height, middlePanelStart, mergeQueueCol);
541
+
542
+ // Metrics panel (bottom strip)
543
+ const middlePanelHeight = Math.floor(height * 0.3);
544
+ const metricsStart = middlePanelStart + middlePanelHeight + 1;
545
+ output += renderMetricsPanel(data, width, height, metricsStart);
546
+
547
+ process.stdout.write(output);
548
+ }
549
+
550
+ /**
551
+ * Entry point for `legio dashboard [--interval <ms>]`.
552
+ */
553
+ const DASHBOARD_HELP = `legio dashboard — Live TUI dashboard for agent monitoring
554
+
555
+ Usage: legio dashboard [--interval <ms>]
556
+
557
+ Options:
558
+ --interval <ms> Poll interval in milliseconds (default: 2000, min: 500)
559
+ --help, -h Show this help
560
+
561
+ Dashboard panels:
562
+ - Agent panel: Active agents with status, capability, bead ID, duration
563
+ - Mail panel: Recent messages with priority and time
564
+ - Merge queue: Pending/merging/conflict entries
565
+ - Metrics: Session counts, avg duration, by-capability breakdown
566
+
567
+ Press Ctrl+C to exit.`;
568
+
569
+ export async function dashboardCommand(args: string[]): Promise<void> {
570
+ if (args.includes("--help") || args.includes("-h")) {
571
+ process.stdout.write(`${DASHBOARD_HELP}\n`);
572
+ return;
573
+ }
574
+
575
+ const intervalStr = getFlag(args, "--interval");
576
+ const interval = intervalStr ? Number.parseInt(intervalStr, 10) : 2000;
577
+
578
+ if (Number.isNaN(interval) || interval < 500) {
579
+ throw new ValidationError("--interval must be a number >= 500 (milliseconds)", {
580
+ field: "interval",
581
+ value: intervalStr,
582
+ });
583
+ }
584
+
585
+ const cwd = process.cwd();
586
+ const config = await loadConfig(cwd);
587
+ const root = config.project.root;
588
+
589
+ // Hide cursor
590
+ process.stdout.write(CURSOR.hideCursor);
591
+
592
+ // Clean exit on Ctrl+C
593
+ let running = true;
594
+ process.on("SIGINT", () => {
595
+ running = false;
596
+ process.stdout.write(CURSOR.showCursor);
597
+ process.stdout.write(CURSOR.clear);
598
+ process.exit(0);
599
+ });
600
+
601
+ // Poll loop
602
+ while (running) {
603
+ const data = await loadDashboardData(root);
604
+ renderDashboard(data, interval);
605
+ await new Promise((resolve) => setTimeout(resolve, interval));
606
+ }
607
+ }