@katyella/legio 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +422 -0
- package/LICENSE +21 -0
- package/README.md +555 -0
- package/agents/builder.md +141 -0
- package/agents/coordinator.md +351 -0
- package/agents/cto.md +196 -0
- package/agents/gateway.md +276 -0
- package/agents/lead.md +281 -0
- package/agents/merger.md +156 -0
- package/agents/monitor.md +212 -0
- package/agents/reviewer.md +142 -0
- package/agents/scout.md +131 -0
- package/agents/supervisor.md +416 -0
- package/bin/legio.mjs +38 -0
- package/package.json +77 -0
- package/src/agents/checkpoint.test.ts +88 -0
- package/src/agents/checkpoint.ts +102 -0
- package/src/agents/hooks-deployer.test.ts +1820 -0
- package/src/agents/hooks-deployer.ts +574 -0
- package/src/agents/identity.test.ts +614 -0
- package/src/agents/identity.ts +385 -0
- package/src/agents/lifecycle.test.ts +202 -0
- package/src/agents/lifecycle.ts +184 -0
- package/src/agents/manifest.test.ts +558 -0
- package/src/agents/manifest.ts +297 -0
- package/src/agents/overlay.test.ts +592 -0
- package/src/agents/overlay.ts +316 -0
- package/src/beads/client.test.ts +210 -0
- package/src/beads/client.ts +227 -0
- package/src/beads/molecules.test.ts +320 -0
- package/src/beads/molecules.ts +209 -0
- package/src/commands/agents.test.ts +325 -0
- package/src/commands/agents.ts +286 -0
- package/src/commands/clean.test.ts +730 -0
- package/src/commands/clean.ts +653 -0
- package/src/commands/completions.test.ts +346 -0
- package/src/commands/completions.ts +950 -0
- package/src/commands/coordinator.test.ts +1524 -0
- package/src/commands/coordinator.ts +880 -0
- package/src/commands/costs.test.ts +1015 -0
- package/src/commands/costs.ts +473 -0
- package/src/commands/dashboard.test.ts +94 -0
- package/src/commands/dashboard.ts +607 -0
- package/src/commands/doctor.test.ts +295 -0
- package/src/commands/doctor.ts +213 -0
- package/src/commands/down.test.ts +308 -0
- package/src/commands/down.ts +124 -0
- package/src/commands/errors.test.ts +648 -0
- package/src/commands/errors.ts +255 -0
- package/src/commands/feed.test.ts +579 -0
- package/src/commands/feed.ts +368 -0
- package/src/commands/gateway.test.ts +698 -0
- package/src/commands/gateway.ts +419 -0
- package/src/commands/group.test.ts +262 -0
- package/src/commands/group.ts +539 -0
- package/src/commands/hooks.test.ts +292 -0
- package/src/commands/hooks.ts +210 -0
- package/src/commands/init.test.ts +211 -0
- package/src/commands/init.ts +622 -0
- package/src/commands/inspect.test.ts +670 -0
- package/src/commands/inspect.ts +455 -0
- package/src/commands/log.test.ts +1556 -0
- package/src/commands/log.ts +752 -0
- package/src/commands/logs.test.ts +379 -0
- package/src/commands/logs.ts +544 -0
- package/src/commands/mail.test.ts +1726 -0
- package/src/commands/mail.ts +926 -0
- package/src/commands/merge.test.ts +676 -0
- package/src/commands/merge.ts +374 -0
- package/src/commands/metrics.test.ts +444 -0
- package/src/commands/metrics.ts +150 -0
- package/src/commands/monitor.test.ts +151 -0
- package/src/commands/monitor.ts +394 -0
- package/src/commands/nudge.test.ts +230 -0
- package/src/commands/nudge.ts +373 -0
- package/src/commands/prime.test.ts +467 -0
- package/src/commands/prime.ts +386 -0
- package/src/commands/replay.test.ts +742 -0
- package/src/commands/replay.ts +367 -0
- package/src/commands/run.test.ts +443 -0
- package/src/commands/run.ts +365 -0
- package/src/commands/server.test.ts +626 -0
- package/src/commands/server.ts +298 -0
- package/src/commands/sling.test.ts +810 -0
- package/src/commands/sling.ts +700 -0
- package/src/commands/spec.test.ts +206 -0
- package/src/commands/spec.ts +171 -0
- package/src/commands/status.test.ts +276 -0
- package/src/commands/status.ts +339 -0
- package/src/commands/stop.test.ts +357 -0
- package/src/commands/stop.ts +119 -0
- package/src/commands/supervisor.test.ts +186 -0
- package/src/commands/supervisor.ts +544 -0
- package/src/commands/trace.test.ts +746 -0
- package/src/commands/trace.ts +332 -0
- package/src/commands/up.test.ts +597 -0
- package/src/commands/up.ts +275 -0
- package/src/commands/watch.test.ts +152 -0
- package/src/commands/watch.ts +238 -0
- package/src/commands/worktree.test.ts +648 -0
- package/src/commands/worktree.ts +266 -0
- package/src/config.test.ts +496 -0
- package/src/config.ts +616 -0
- package/src/doctor/agents.test.ts +448 -0
- package/src/doctor/agents.ts +396 -0
- package/src/doctor/config-check.test.ts +184 -0
- package/src/doctor/config-check.ts +185 -0
- package/src/doctor/consistency.test.ts +645 -0
- package/src/doctor/consistency.ts +294 -0
- package/src/doctor/databases.test.ts +284 -0
- package/src/doctor/databases.ts +211 -0
- package/src/doctor/dependencies.test.ts +150 -0
- package/src/doctor/dependencies.ts +179 -0
- package/src/doctor/logs.test.ts +244 -0
- package/src/doctor/logs.ts +295 -0
- package/src/doctor/merge-queue.test.ts +210 -0
- package/src/doctor/merge-queue.ts +144 -0
- package/src/doctor/structure.test.ts +285 -0
- package/src/doctor/structure.ts +195 -0
- package/src/doctor/types.ts +37 -0
- package/src/doctor/version.test.ts +130 -0
- package/src/doctor/version.ts +131 -0
- package/src/e2e/chat-flow.test.ts +346 -0
- package/src/e2e/init-sling-lifecycle.test.ts +288 -0
- package/src/errors.test.ts +21 -0
- package/src/errors.ts +246 -0
- package/src/events/store.test.ts +660 -0
- package/src/events/store.ts +344 -0
- package/src/events/tool-filter.test.ts +330 -0
- package/src/events/tool-filter.ts +126 -0
- package/src/global-setup.ts +14 -0
- package/src/index.ts +339 -0
- package/src/insights/analyzer.test.ts +466 -0
- package/src/insights/analyzer.ts +203 -0
- package/src/logging/color.test.ts +118 -0
- package/src/logging/color.ts +71 -0
- package/src/logging/logger.test.ts +812 -0
- package/src/logging/logger.ts +266 -0
- package/src/logging/reporter.test.ts +258 -0
- package/src/logging/reporter.ts +109 -0
- package/src/logging/sanitizer.test.ts +190 -0
- package/src/logging/sanitizer.ts +57 -0
- package/src/mail/broadcast.test.ts +203 -0
- package/src/mail/broadcast.ts +92 -0
- package/src/mail/client.test.ts +873 -0
- package/src/mail/client.ts +236 -0
- package/src/mail/store.test.ts +815 -0
- package/src/mail/store.ts +402 -0
- package/src/merge/queue.test.ts +449 -0
- package/src/merge/queue.ts +262 -0
- package/src/merge/resolver.test.ts +1453 -0
- package/src/merge/resolver.ts +759 -0
- package/src/metrics/store.test.ts +1167 -0
- package/src/metrics/store.ts +511 -0
- package/src/metrics/summary.test.ts +397 -0
- package/src/metrics/summary.ts +178 -0
- package/src/metrics/transcript.test.ts +643 -0
- package/src/metrics/transcript.ts +351 -0
- package/src/mulch/client.test.ts +547 -0
- package/src/mulch/client.ts +416 -0
- package/src/server/audit-store.test.ts +384 -0
- package/src/server/audit-store.ts +257 -0
- package/src/server/headless.test.ts +180 -0
- package/src/server/headless.ts +151 -0
- package/src/server/index.test.ts +241 -0
- package/src/server/index.ts +317 -0
- package/src/server/public/app.js +187 -0
- package/src/server/public/apple-touch-icon.png +0 -0
- package/src/server/public/components/agent-badge.js +37 -0
- package/src/server/public/components/data-table.js +114 -0
- package/src/server/public/components/gateway-chat.js +256 -0
- package/src/server/public/components/issue-card.js +96 -0
- package/src/server/public/components/layout.js +88 -0
- package/src/server/public/components/message-bubble.js +120 -0
- package/src/server/public/components/stat-card.js +26 -0
- package/src/server/public/components/terminal-panel.js +140 -0
- package/src/server/public/favicon-16.png +0 -0
- package/src/server/public/favicon-32.png +0 -0
- package/src/server/public/favicon.ico +0 -0
- package/src/server/public/favicon.png +0 -0
- package/src/server/public/index.html +64 -0
- package/src/server/public/lib/api.js +35 -0
- package/src/server/public/lib/markdown.js +8 -0
- package/src/server/public/lib/preact-setup.js +8 -0
- package/src/server/public/lib/state.js +99 -0
- package/src/server/public/lib/utils.js +309 -0
- package/src/server/public/lib/ws.js +79 -0
- package/src/server/public/views/chat.js +983 -0
- package/src/server/public/views/costs.js +692 -0
- package/src/server/public/views/dashboard.js +781 -0
- package/src/server/public/views/gateway-chat.js +622 -0
- package/src/server/public/views/inspect.js +399 -0
- package/src/server/public/views/issues.js +470 -0
- package/src/server/public/views/setup.js +94 -0
- package/src/server/public/views/task-detail.js +422 -0
- package/src/server/routes.test.ts +3816 -0
- package/src/server/routes.ts +1964 -0
- package/src/server/websocket.test.ts +288 -0
- package/src/server/websocket.ts +196 -0
- package/src/sessions/compat.test.ts +109 -0
- package/src/sessions/compat.ts +17 -0
- package/src/sessions/store.test.ts +969 -0
- package/src/sessions/store.ts +480 -0
- package/src/test-helpers.test.ts +97 -0
- package/src/test-helpers.ts +143 -0
- package/src/types.ts +708 -0
- package/src/watchdog/daemon.test.ts +1233 -0
- package/src/watchdog/daemon.ts +533 -0
- package/src/watchdog/health.test.ts +371 -0
- package/src/watchdog/health.ts +248 -0
- package/src/watchdog/triage.test.ts +162 -0
- package/src/watchdog/triage.ts +193 -0
- package/src/worktree/manager.test.ts +444 -0
- package/src/worktree/manager.ts +224 -0
- package/src/worktree/tmux.test.ts +1238 -0
- package/src/worktree/tmux.ts +644 -0
- package/templates/CLAUDE.md.tmpl +89 -0
- package/templates/hooks.json.tmpl +132 -0
- package/templates/overlay.md.tmpl +79 -0
|
@@ -0,0 +1,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
|
+
}
|