@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,544 @@
1
+ /**
2
+ * CLI command: legio logs [--agent <name>] [--level <level>] [--since <time>] [--until <time>] [--limit <n>] [--follow] [--json]
3
+ *
4
+ * Queries NDJSON log files from .legio/logs/{agent-name}/{session-timestamp}/events.ndjson
5
+ * and presents a unified timeline view.
6
+ *
7
+ * Unlike trace/errors/replay which query events.db (SQLite), this command reads raw NDJSON files
8
+ * on disk — the source of truth written by each agent logger.
9
+ */
10
+
11
+ import { readdir, readFile, stat } from "node:fs/promises";
12
+ import { join } from "node:path";
13
+ import { loadConfig } from "../config.ts";
14
+ import { ValidationError } from "../errors.ts";
15
+ import { color } from "../logging/color.ts";
16
+ import type { LogEvent } from "../types.ts";
17
+
18
+ /**
19
+ * Parse a named flag value from args.
20
+ */
21
+ function getFlag(args: string[], flag: string): string | undefined {
22
+ const idx = args.indexOf(flag);
23
+ if (idx === -1 || idx + 1 >= args.length) {
24
+ return undefined;
25
+ }
26
+ return args[idx + 1];
27
+ }
28
+
29
+ function hasFlag(args: string[], flag: string): boolean {
30
+ return args.includes(flag);
31
+ }
32
+
33
+ /**
34
+ * Parse relative time formats like "1h", "30m", "2d", "10s" into a Date object.
35
+ * Falls back to parsing as ISO 8601 if not in relative format.
36
+ */
37
+ function parseRelativeTime(timeStr: string): Date {
38
+ const relativeMatch = /^(\d+)(s|m|h|d)$/.exec(timeStr);
39
+ if (relativeMatch) {
40
+ const value = Number.parseInt(relativeMatch[1] ?? "0", 10);
41
+ const unit = relativeMatch[2];
42
+ const now = Date.now();
43
+ let offsetMs = 0;
44
+
45
+ switch (unit) {
46
+ case "s":
47
+ offsetMs = value * 1000;
48
+ break;
49
+ case "m":
50
+ offsetMs = value * 60 * 1000;
51
+ break;
52
+ case "h":
53
+ offsetMs = value * 60 * 60 * 1000;
54
+ break;
55
+ case "d":
56
+ offsetMs = value * 24 * 60 * 60 * 1000;
57
+ break;
58
+ }
59
+
60
+ return new Date(now - offsetMs);
61
+ }
62
+
63
+ // Not a relative format, treat as ISO 8601
64
+ return new Date(timeStr);
65
+ }
66
+
67
+ /**
68
+ * Format the date portion of an ISO timestamp.
69
+ * Returns "YYYY-MM-DD".
70
+ */
71
+ function formatDate(timestamp: string): string {
72
+ const match = /^(\d{4}-\d{2}-\d{2})/.exec(timestamp);
73
+ if (match?.[1]) {
74
+ return match[1];
75
+ }
76
+ return "";
77
+ }
78
+
79
+ /**
80
+ * Format an absolute time from an ISO timestamp.
81
+ * Returns "HH:MM:SS" portion.
82
+ */
83
+ function formatAbsoluteTime(timestamp: string): string {
84
+ const match = /T(\d{2}:\d{2}:\d{2})/.exec(timestamp);
85
+ if (match?.[1]) {
86
+ return match[1];
87
+ }
88
+ return timestamp;
89
+ }
90
+
91
+ /**
92
+ * Build a detail string for a log event based on its data.
93
+ */
94
+ function buildLogDetail(event: LogEvent): string {
95
+ const parts: string[] = [];
96
+
97
+ for (const [key, value] of Object.entries(event.data)) {
98
+ if (value !== null && value !== undefined) {
99
+ const strValue = typeof value === "string" ? value : JSON.stringify(value);
100
+ // Truncate long values
101
+ const truncated = strValue.length > 80 ? `${strValue.slice(0, 77)}...` : strValue;
102
+ parts.push(`${key}=${truncated}`);
103
+ }
104
+ }
105
+
106
+ return parts.join(" ");
107
+ }
108
+
109
+ /**
110
+ * Discover all events.ndjson files in the logs directory.
111
+ * Returns array of { agentName, sessionTimestamp, path }.
112
+ */
113
+ async function discoverLogFiles(
114
+ logsDir: string,
115
+ agentFilter?: string,
116
+ ): Promise<
117
+ Array<{
118
+ agentName: string;
119
+ sessionTimestamp: string;
120
+ path: string;
121
+ }>
122
+ > {
123
+ const discovered: Array<{
124
+ agentName: string;
125
+ sessionTimestamp: string;
126
+ path: string;
127
+ }> = [];
128
+
129
+ try {
130
+ const agentDirs = await readdir(logsDir);
131
+
132
+ for (const agentName of agentDirs) {
133
+ if (agentFilter !== undefined && agentName !== agentFilter) {
134
+ continue;
135
+ }
136
+
137
+ const agentDir = join(logsDir, agentName);
138
+ let agentStat: Awaited<ReturnType<typeof stat>>;
139
+ try {
140
+ agentStat = await stat(agentDir);
141
+ } catch {
142
+ continue; // Not a directory or doesn't exist
143
+ }
144
+
145
+ if (!agentStat.isDirectory()) {
146
+ continue;
147
+ }
148
+
149
+ const sessionDirs = await readdir(agentDir);
150
+
151
+ for (const sessionTimestamp of sessionDirs) {
152
+ const eventsPath = join(agentDir, sessionTimestamp, "events.ndjson");
153
+ let eventsStat: Awaited<ReturnType<typeof stat>>;
154
+ try {
155
+ eventsStat = await stat(eventsPath);
156
+ } catch {
157
+ continue; // File doesn't exist
158
+ }
159
+
160
+ if (eventsStat.isFile()) {
161
+ discovered.push({
162
+ agentName,
163
+ sessionTimestamp,
164
+ path: eventsPath,
165
+ });
166
+ }
167
+ }
168
+ }
169
+ } catch {
170
+ // Logs directory doesn't exist or can't be read
171
+ return [];
172
+ }
173
+
174
+ // Sort by session timestamp (chronological)
175
+ discovered.sort((a, b) => a.sessionTimestamp.localeCompare(b.sessionTimestamp));
176
+
177
+ return discovered;
178
+ }
179
+
180
+ /**
181
+ * Parse a single NDJSON file and return log events.
182
+ * Silently skips invalid lines.
183
+ */
184
+ async function parseLogFile(path: string): Promise<LogEvent[]> {
185
+ const events: LogEvent[] = [];
186
+
187
+ try {
188
+ const text = await readFile(path, "utf-8");
189
+ const lines = text.split("\n");
190
+
191
+ for (const line of lines) {
192
+ if (line.trim() === "") {
193
+ continue;
194
+ }
195
+
196
+ try {
197
+ const parsed: unknown = JSON.parse(line);
198
+ // Validate that it has required LogEvent fields
199
+ if (
200
+ typeof parsed === "object" &&
201
+ parsed !== null &&
202
+ "timestamp" in parsed &&
203
+ "event" in parsed
204
+ ) {
205
+ events.push(parsed as LogEvent);
206
+ }
207
+ } catch {
208
+ // Invalid JSON line, skip silently
209
+ }
210
+ }
211
+ } catch {
212
+ // File can't be read, return empty array
213
+ return [];
214
+ }
215
+
216
+ return events;
217
+ }
218
+
219
+ /**
220
+ * Apply filters to log events.
221
+ */
222
+ function filterEvents(
223
+ events: LogEvent[],
224
+ filters: {
225
+ level?: string;
226
+ since?: Date;
227
+ until?: Date;
228
+ },
229
+ ): LogEvent[] {
230
+ return events.filter((event) => {
231
+ if (filters.level !== undefined && event.level !== filters.level) {
232
+ return false;
233
+ }
234
+
235
+ const eventTime = new Date(event.timestamp).getTime();
236
+
237
+ if (filters.since !== undefined && eventTime < filters.since.getTime()) {
238
+ return false;
239
+ }
240
+
241
+ if (filters.until !== undefined && eventTime > filters.until.getTime()) {
242
+ return false;
243
+ }
244
+
245
+ return true;
246
+ });
247
+ }
248
+
249
+ /**
250
+ * Print log events with ANSI colors and date separators.
251
+ */
252
+ function printLogs(events: LogEvent[]): void {
253
+ const w = process.stdout.write.bind(process.stdout);
254
+
255
+ w(`${color.bold}Logs${color.reset}\n`);
256
+ w(`${"=".repeat(70)}\n`);
257
+
258
+ if (events.length === 0) {
259
+ w(`${color.dim}No log files found.${color.reset}\n`);
260
+ return;
261
+ }
262
+
263
+ w(`${color.dim}${events.length} ${events.length === 1 ? "entry" : "entries"}${color.reset}\n\n`);
264
+
265
+ let lastDate = "";
266
+
267
+ for (const event of events) {
268
+ // Print date separator when the date changes
269
+ const date = formatDate(event.timestamp);
270
+ if (date && date !== lastDate) {
271
+ if (lastDate !== "") {
272
+ w("\n");
273
+ }
274
+ w(`${color.dim}--- ${date} ---${color.reset}\n`);
275
+ lastDate = date;
276
+ }
277
+
278
+ const time = formatAbsoluteTime(event.timestamp);
279
+
280
+ // Format level display
281
+ let levelStr: string;
282
+ let levelColorCode: string;
283
+ switch (event.level) {
284
+ case "debug":
285
+ levelStr = "DBG";
286
+ levelColorCode = color.gray;
287
+ break;
288
+ case "info":
289
+ levelStr = "INF";
290
+ levelColorCode = color.blue;
291
+ break;
292
+ case "warn":
293
+ levelStr = "WRN";
294
+ levelColorCode = color.yellow;
295
+ break;
296
+ case "error":
297
+ levelStr = "ERR";
298
+ levelColorCode = color.red;
299
+ break;
300
+ default:
301
+ levelStr = String(event.level).slice(0, 3).toUpperCase();
302
+ levelColorCode = color.gray;
303
+ }
304
+
305
+ const agentLabel = event.agentName ? `[${event.agentName}]` : "[unknown]";
306
+ const detail = buildLogDetail(event);
307
+ const detailSuffix = detail ? ` ${color.dim}${detail}${color.reset}` : "";
308
+
309
+ w(
310
+ `${time} ${levelColorCode}${levelStr}${color.reset} ` +
311
+ `${event.event} ${color.dim}${agentLabel}${color.reset}${detailSuffix}\n`,
312
+ );
313
+ }
314
+ }
315
+
316
+ /**
317
+ * Follow mode: tail logs in real time.
318
+ */
319
+ async function followLogs(
320
+ logsDir: string,
321
+ filters: {
322
+ agent?: string;
323
+ level?: string;
324
+ },
325
+ ): Promise<void> {
326
+ const w = process.stdout.write.bind(process.stdout);
327
+
328
+ w(`${color.bold}Following logs (Ctrl+C to stop)${color.reset}\n\n`);
329
+
330
+ // Track file positions for tailing
331
+ const filePositions = new Map<string, number>();
332
+
333
+ while (true) {
334
+ const discovered = await discoverLogFiles(logsDir, filters.agent);
335
+
336
+ for (const { path } of discovered) {
337
+ let fileSize: number;
338
+
339
+ try {
340
+ const fileStat = await stat(path);
341
+ fileSize = fileStat.size;
342
+ } catch {
343
+ continue; // File disappeared
344
+ }
345
+
346
+ const lastPosition = filePositions.get(path) ?? 0;
347
+
348
+ if (fileSize > lastPosition) {
349
+ // New data available
350
+ try {
351
+ const fullText = await readFile(path, "utf-8");
352
+ const newText = fullText.slice(lastPosition);
353
+ const lines = newText.split("\n");
354
+
355
+ for (const line of lines) {
356
+ if (line.trim() === "") {
357
+ continue;
358
+ }
359
+
360
+ try {
361
+ const parsed: unknown = JSON.parse(line);
362
+ if (
363
+ typeof parsed === "object" &&
364
+ parsed !== null &&
365
+ "timestamp" in parsed &&
366
+ "event" in parsed
367
+ ) {
368
+ const event = parsed as LogEvent;
369
+
370
+ // Apply level filter
371
+ if (filters.level !== undefined && event.level !== filters.level) {
372
+ continue;
373
+ }
374
+
375
+ // Print immediately
376
+ const time = formatAbsoluteTime(event.timestamp);
377
+
378
+ let levelStr: string;
379
+ let levelColorCode: string;
380
+ switch (event.level) {
381
+ case "debug":
382
+ levelStr = "DBG";
383
+ levelColorCode = color.gray;
384
+ break;
385
+ case "info":
386
+ levelStr = "INF";
387
+ levelColorCode = color.blue;
388
+ break;
389
+ case "warn":
390
+ levelStr = "WRN";
391
+ levelColorCode = color.yellow;
392
+ break;
393
+ case "error":
394
+ levelStr = "ERR";
395
+ levelColorCode = color.red;
396
+ break;
397
+ default:
398
+ levelStr = String(event.level).slice(0, 3).toUpperCase();
399
+ levelColorCode = color.gray;
400
+ }
401
+
402
+ const agentLabel = event.agentName ? `[${event.agentName}]` : "[unknown]";
403
+ const detail = buildLogDetail(event);
404
+ const detailSuffix = detail ? ` ${color.dim}${detail}${color.reset}` : "";
405
+
406
+ w(
407
+ `${time} ${levelColorCode}${levelStr}${color.reset} ` +
408
+ `${event.event} ${color.dim}${agentLabel}${color.reset}${detailSuffix}\n`,
409
+ );
410
+ }
411
+ } catch {
412
+ // Invalid JSON line, skip
413
+ }
414
+ }
415
+
416
+ filePositions.set(path, fileSize);
417
+ } catch {
418
+ // File read error, skip
419
+ }
420
+ }
421
+ }
422
+
423
+ // Sleep for 1 second before next poll
424
+ await new Promise((resolve) => setTimeout(resolve, 1000));
425
+ }
426
+ }
427
+
428
+ const LOGS_HELP = `legio logs -- Query NDJSON log files from .legio/logs
429
+
430
+ Usage: legio logs [options]
431
+
432
+ Options:
433
+ --agent <name> Filter logs by agent name
434
+ --level <level> Filter by log level: debug, info, warn, error
435
+ --since <time> Start time filter (ISO 8601 or relative: 1h, 30m, 2d, 10s)
436
+ --until <time> End time filter (ISO 8601)
437
+ --limit <n> Max entries to show (default: 100, returns most recent)
438
+ --follow Tail logs in real time (poll every 1s, Ctrl+C to stop)
439
+ --json Output as JSON array of LogEvent objects
440
+ --help, -h Show this help`;
441
+
442
+ /**
443
+ * Entry point for `legio logs` command.
444
+ */
445
+ export async function logsCommand(args: string[]): Promise<void> {
446
+ if (args.includes("--help") || args.includes("-h")) {
447
+ process.stdout.write(`${LOGS_HELP}\n`);
448
+ return;
449
+ }
450
+
451
+ const json = hasFlag(args, "--json");
452
+ const follow = hasFlag(args, "--follow");
453
+ const agentName = getFlag(args, "--agent");
454
+ const level = getFlag(args, "--level");
455
+ const sinceStr = getFlag(args, "--since");
456
+ const untilStr = getFlag(args, "--until");
457
+ const limitStr = getFlag(args, "--limit");
458
+ const limit = limitStr ? Number.parseInt(limitStr, 10) : 100;
459
+
460
+ if (Number.isNaN(limit) || limit < 1) {
461
+ throw new ValidationError("--limit must be a positive integer", {
462
+ field: "limit",
463
+ value: limitStr,
464
+ });
465
+ }
466
+
467
+ // Validate level if provided
468
+ if (level !== undefined && !["debug", "info", "warn", "error"].includes(level)) {
469
+ throw new ValidationError("--level must be one of: debug, info, warn, error", {
470
+ field: "level",
471
+ value: level,
472
+ });
473
+ }
474
+
475
+ // Parse time filters
476
+ let since: Date | undefined;
477
+ let until: Date | undefined;
478
+
479
+ if (sinceStr !== undefined) {
480
+ since = parseRelativeTime(sinceStr);
481
+ if (Number.isNaN(since.getTime())) {
482
+ throw new ValidationError("--since must be a valid ISO 8601 timestamp or relative time", {
483
+ field: "since",
484
+ value: sinceStr,
485
+ });
486
+ }
487
+ }
488
+
489
+ if (untilStr !== undefined) {
490
+ until = new Date(untilStr);
491
+ if (Number.isNaN(until.getTime())) {
492
+ throw new ValidationError("--until must be a valid ISO 8601 timestamp", {
493
+ field: "until",
494
+ value: untilStr,
495
+ });
496
+ }
497
+ }
498
+
499
+ const cwd = process.cwd();
500
+ const config = await loadConfig(cwd);
501
+ const logsDir = join(config.project.root, ".legio", "logs");
502
+
503
+ // Follow mode: tail logs in real time
504
+ if (follow) {
505
+ await followLogs(logsDir, { agent: agentName, level });
506
+ return;
507
+ }
508
+
509
+ // Discovery phase: find all events.ndjson files
510
+ const discovered = await discoverLogFiles(logsDir, agentName);
511
+
512
+ if (discovered.length === 0) {
513
+ if (json) {
514
+ process.stdout.write("[]\n");
515
+ } else {
516
+ process.stdout.write("No log files found.\n");
517
+ }
518
+ return;
519
+ }
520
+
521
+ // Parsing phase: read and parse all files
522
+ const allEvents: LogEvent[] = [];
523
+
524
+ for (const { path } of discovered) {
525
+ const events = await parseLogFile(path);
526
+ allEvents.push(...events);
527
+ }
528
+
529
+ // Apply filters
530
+ const filtered = filterEvents(allEvents, { level, since, until });
531
+
532
+ // Sort by timestamp chronologically
533
+ filtered.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
534
+
535
+ // Apply limit: take the LAST N entries (most recent)
536
+ const limited = filtered.slice(-limit);
537
+
538
+ if (json) {
539
+ process.stdout.write(`${JSON.stringify(limited)}\n`);
540
+ return;
541
+ }
542
+
543
+ printLogs(limited);
544
+ }