@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,339 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CLI command: legio status [--json] [--watch]
|
|
3
|
+
*
|
|
4
|
+
* Shows active agents, worktree status, beads summary, mail queue depth,
|
|
5
|
+
* and merge queue state. --watch mode uses polling for live updates.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { access, readFile } from "node:fs/promises";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { loadConfig } from "../config.ts";
|
|
11
|
+
import { ValidationError } from "../errors.ts";
|
|
12
|
+
import { createMailStore } from "../mail/store.ts";
|
|
13
|
+
import { createMergeQueue } from "../merge/queue.ts";
|
|
14
|
+
import { createMetricsStore } from "../metrics/store.ts";
|
|
15
|
+
import { openSessionStore } from "../sessions/compat.ts";
|
|
16
|
+
import type { AgentSession } from "../types.ts";
|
|
17
|
+
import { listWorktrees } from "../worktree/manager.ts";
|
|
18
|
+
import { listSessions } from "../worktree/tmux.ts";
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Parse a named flag value from args.
|
|
22
|
+
*/
|
|
23
|
+
function getFlag(args: string[], flag: string): string | undefined {
|
|
24
|
+
const idx = args.indexOf(flag);
|
|
25
|
+
if (idx === -1 || idx + 1 >= args.length) {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
return args[idx + 1];
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function hasFlag(args: string[], flag: string): boolean {
|
|
32
|
+
return args.includes(flag);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Format a duration in ms to a human-readable string.
|
|
37
|
+
*/
|
|
38
|
+
function formatDuration(ms: number): string {
|
|
39
|
+
const seconds = Math.floor(ms / 1000);
|
|
40
|
+
if (seconds < 60) return `${seconds}s`;
|
|
41
|
+
const minutes = Math.floor(seconds / 60);
|
|
42
|
+
const remainSec = seconds % 60;
|
|
43
|
+
if (minutes < 60) return `${minutes}m ${remainSec}s`;
|
|
44
|
+
const hours = Math.floor(minutes / 60);
|
|
45
|
+
const remainMin = minutes % 60;
|
|
46
|
+
return `${hours}h ${remainMin}m`;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface VerboseAgentDetail {
|
|
50
|
+
worktreePath: string;
|
|
51
|
+
logsDir: string;
|
|
52
|
+
lastMailSent: string | null;
|
|
53
|
+
lastMailReceived: string | null;
|
|
54
|
+
capability: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export interface StatusData {
|
|
58
|
+
agents: AgentSession[];
|
|
59
|
+
worktrees: Array<{ path: string; branch: string; head: string }>;
|
|
60
|
+
tmuxSessions: Array<{ name: string; pid: number }>;
|
|
61
|
+
unreadMailCount: number;
|
|
62
|
+
mergeQueueCount: number;
|
|
63
|
+
recentMetricsCount: number;
|
|
64
|
+
verboseDetails?: Record<string, VerboseAgentDetail>;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Gather all status data.
|
|
69
|
+
* @param agentName - Which agent's perspective for unread mail count (default "orchestrator")
|
|
70
|
+
* @param verbose - When true, collect extra per-agent detail (worktree path, logs dir, last mail)
|
|
71
|
+
*/
|
|
72
|
+
export async function gatherStatus(
|
|
73
|
+
root: string,
|
|
74
|
+
agentName = "orchestrator",
|
|
75
|
+
verbose = false,
|
|
76
|
+
): Promise<StatusData> {
|
|
77
|
+
const legioDir = join(root, ".legio");
|
|
78
|
+
const { store } = openSessionStore(legioDir);
|
|
79
|
+
|
|
80
|
+
let sessions: AgentSession[];
|
|
81
|
+
try {
|
|
82
|
+
const currentRunPath = join(legioDir, "current-run.txt");
|
|
83
|
+
let runId: string | undefined;
|
|
84
|
+
try {
|
|
85
|
+
runId = (await readFile(currentRunPath, "utf8")).trim();
|
|
86
|
+
} catch {
|
|
87
|
+
// current-run.txt does not exist
|
|
88
|
+
}
|
|
89
|
+
sessions = runId ? store.getByRunIncludeOrphans(runId) : store.getActive();
|
|
90
|
+
|
|
91
|
+
const worktrees = await listWorktrees(root);
|
|
92
|
+
|
|
93
|
+
let tmuxSessions: Array<{ name: string; pid: number }> = [];
|
|
94
|
+
try {
|
|
95
|
+
tmuxSessions = await listSessions();
|
|
96
|
+
} catch {
|
|
97
|
+
// tmux might not be running
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Reconcile agent states: if tmux session is dead but agent state
|
|
101
|
+
// indicates it should be alive, mark it as zombie
|
|
102
|
+
for (const session of sessions) {
|
|
103
|
+
if (session.state === "booting" || session.state === "working") {
|
|
104
|
+
const tmuxAlive = tmuxSessions.some((s) => s.name === session.tmuxSession);
|
|
105
|
+
if (!tmuxAlive) {
|
|
106
|
+
try {
|
|
107
|
+
store.updateState(session.agentName, "zombie");
|
|
108
|
+
session.state = "zombie";
|
|
109
|
+
} catch {
|
|
110
|
+
// Best effort: don't fail status display if update fails
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
let unreadMailCount = 0;
|
|
117
|
+
let mailStore: ReturnType<typeof createMailStore> | null = null;
|
|
118
|
+
try {
|
|
119
|
+
const mailDbPath = join(root, ".legio", "mail.db");
|
|
120
|
+
let mailDbExists = false;
|
|
121
|
+
try {
|
|
122
|
+
await access(mailDbPath);
|
|
123
|
+
mailDbExists = true;
|
|
124
|
+
} catch {
|
|
125
|
+
/* not found */
|
|
126
|
+
}
|
|
127
|
+
if (mailDbExists) {
|
|
128
|
+
mailStore = createMailStore(mailDbPath);
|
|
129
|
+
const unread = mailStore.getAll({ to: agentName, unread: true });
|
|
130
|
+
unreadMailCount = unread.length;
|
|
131
|
+
}
|
|
132
|
+
} catch {
|
|
133
|
+
// mail db might not exist
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let mergeQueueCount = 0;
|
|
137
|
+
try {
|
|
138
|
+
const queuePath = join(root, ".legio", "merge-queue.db");
|
|
139
|
+
const queue = createMergeQueue(queuePath);
|
|
140
|
+
mergeQueueCount = queue.list("pending").length;
|
|
141
|
+
queue.close();
|
|
142
|
+
} catch {
|
|
143
|
+
// queue might not exist
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
let recentMetricsCount = 0;
|
|
147
|
+
try {
|
|
148
|
+
const metricsDbPath = join(root, ".legio", "metrics.db");
|
|
149
|
+
let metricsDbExists = false;
|
|
150
|
+
try {
|
|
151
|
+
await access(metricsDbPath);
|
|
152
|
+
metricsDbExists = true;
|
|
153
|
+
} catch {
|
|
154
|
+
/* not found */
|
|
155
|
+
}
|
|
156
|
+
if (metricsDbExists) {
|
|
157
|
+
const metricsStore = createMetricsStore(metricsDbPath);
|
|
158
|
+
recentMetricsCount = metricsStore.getRecentSessions(100).length;
|
|
159
|
+
metricsStore.close();
|
|
160
|
+
}
|
|
161
|
+
} catch {
|
|
162
|
+
// metrics db might not exist
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
let verboseDetails: Record<string, VerboseAgentDetail> | undefined;
|
|
166
|
+
if (verbose && sessions.length > 0) {
|
|
167
|
+
verboseDetails = {};
|
|
168
|
+
for (const session of sessions) {
|
|
169
|
+
const logsDir = join(root, ".legio", "logs", session.agentName);
|
|
170
|
+
|
|
171
|
+
let lastMailSent: string | null = null;
|
|
172
|
+
let lastMailReceived: string | null = null;
|
|
173
|
+
if (mailStore) {
|
|
174
|
+
try {
|
|
175
|
+
const sent = mailStore.getAll({ from: session.agentName });
|
|
176
|
+
if (sent.length > 0 && sent[0]) {
|
|
177
|
+
lastMailSent = sent[0].createdAt;
|
|
178
|
+
}
|
|
179
|
+
const received = mailStore.getAll({ to: session.agentName });
|
|
180
|
+
if (received.length > 0 && received[0]) {
|
|
181
|
+
lastMailReceived = received[0].createdAt;
|
|
182
|
+
}
|
|
183
|
+
} catch {
|
|
184
|
+
// Best effort
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
verboseDetails[session.agentName] = {
|
|
189
|
+
worktreePath: session.worktreePath,
|
|
190
|
+
logsDir,
|
|
191
|
+
lastMailSent,
|
|
192
|
+
lastMailReceived,
|
|
193
|
+
capability: session.capability,
|
|
194
|
+
};
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (mailStore) {
|
|
199
|
+
mailStore.close();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
agents: sessions,
|
|
204
|
+
worktrees,
|
|
205
|
+
tmuxSessions,
|
|
206
|
+
unreadMailCount,
|
|
207
|
+
mergeQueueCount,
|
|
208
|
+
recentMetricsCount,
|
|
209
|
+
verboseDetails,
|
|
210
|
+
};
|
|
211
|
+
} finally {
|
|
212
|
+
store.close();
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Print status in human-readable format.
|
|
218
|
+
*/
|
|
219
|
+
export function printStatus(data: StatusData): void {
|
|
220
|
+
const now = Date.now();
|
|
221
|
+
const w = process.stdout.write.bind(process.stdout);
|
|
222
|
+
|
|
223
|
+
w("📊 Legio Status\n");
|
|
224
|
+
w(`${"═".repeat(60)}\n\n`);
|
|
225
|
+
|
|
226
|
+
// Active agents
|
|
227
|
+
const active = data.agents.filter((a) => a.state !== "zombie" && a.state !== "completed");
|
|
228
|
+
w(`🤖 Agents: ${active.length} active\n`);
|
|
229
|
+
if (active.length > 0) {
|
|
230
|
+
for (const agent of active) {
|
|
231
|
+
const endTime =
|
|
232
|
+
agent.state === "completed" || agent.state === "zombie"
|
|
233
|
+
? new Date(agent.lastActivity).getTime()
|
|
234
|
+
: now;
|
|
235
|
+
const duration = formatDuration(endTime - new Date(agent.startedAt).getTime());
|
|
236
|
+
const tmuxAlive = data.tmuxSessions.some((s) => s.name === agent.tmuxSession);
|
|
237
|
+
const aliveMarker = tmuxAlive ? "●" : "○";
|
|
238
|
+
w(` ${aliveMarker} ${agent.agentName} [${agent.capability}] `);
|
|
239
|
+
w(`${agent.state} | ${agent.beadId} | ${duration}\n`);
|
|
240
|
+
|
|
241
|
+
const detail = data.verboseDetails?.[agent.agentName];
|
|
242
|
+
if (detail) {
|
|
243
|
+
w(` Worktree: ${detail.worktreePath}\n`);
|
|
244
|
+
w(` Logs: ${detail.logsDir}\n`);
|
|
245
|
+
w(` Mail sent: ${detail.lastMailSent ?? "none"}`);
|
|
246
|
+
w(` | received: ${detail.lastMailReceived ?? "none"}\n`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
} else {
|
|
250
|
+
w(" No active agents\n");
|
|
251
|
+
}
|
|
252
|
+
w("\n");
|
|
253
|
+
|
|
254
|
+
// Worktrees
|
|
255
|
+
const legioWts = data.worktrees.filter((wt) => wt.branch.startsWith("legio/"));
|
|
256
|
+
w(`🌳 Worktrees: ${legioWts.length}\n`);
|
|
257
|
+
for (const wt of legioWts) {
|
|
258
|
+
w(` ${wt.branch}\n`);
|
|
259
|
+
}
|
|
260
|
+
if (legioWts.length === 0) {
|
|
261
|
+
w(" No agent worktrees\n");
|
|
262
|
+
}
|
|
263
|
+
w("\n");
|
|
264
|
+
|
|
265
|
+
// Mail
|
|
266
|
+
w(`📬 Mail: ${data.unreadMailCount} unread\n`);
|
|
267
|
+
|
|
268
|
+
// Merge queue
|
|
269
|
+
w(`🔀 Merge queue: ${data.mergeQueueCount} pending\n`);
|
|
270
|
+
|
|
271
|
+
// Metrics
|
|
272
|
+
w(`📈 Sessions recorded: ${data.recentMetricsCount}\n`);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* Entry point for `legio status [--json] [--watch]`.
|
|
277
|
+
*/
|
|
278
|
+
const STATUS_HELP = `legio status — Show all active agents and project state
|
|
279
|
+
|
|
280
|
+
Usage: legio status [--json] [--verbose] [--agent <name>]
|
|
281
|
+
|
|
282
|
+
Options:
|
|
283
|
+
--json Output as JSON
|
|
284
|
+
--verbose Show extra detail per agent (worktree, logs, mail timestamps)
|
|
285
|
+
--agent <name> Show unread mail for this agent (default: orchestrator)
|
|
286
|
+
--watch (deprecated) Use 'legio dashboard' for live monitoring
|
|
287
|
+
--interval <ms> Poll interval for --watch in milliseconds (default: 3000)
|
|
288
|
+
--help, -h Show this help`;
|
|
289
|
+
|
|
290
|
+
export async function statusCommand(args: string[]): Promise<void> {
|
|
291
|
+
if (args.includes("--help") || args.includes("-h")) {
|
|
292
|
+
process.stdout.write(`${STATUS_HELP}\n`);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const json = hasFlag(args, "--json");
|
|
297
|
+
const watch = hasFlag(args, "--watch");
|
|
298
|
+
const verbose = hasFlag(args, "--verbose");
|
|
299
|
+
const intervalStr = getFlag(args, "--interval");
|
|
300
|
+
const interval = intervalStr ? Number.parseInt(intervalStr, 10) : 3000;
|
|
301
|
+
|
|
302
|
+
if (Number.isNaN(interval) || interval < 500) {
|
|
303
|
+
throw new ValidationError("--interval must be a number >= 500 (milliseconds)", {
|
|
304
|
+
field: "interval",
|
|
305
|
+
value: intervalStr,
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
const agentName = getFlag(args, "--agent") ?? "orchestrator";
|
|
310
|
+
|
|
311
|
+
const cwd = process.cwd();
|
|
312
|
+
const config = await loadConfig(cwd);
|
|
313
|
+
const root = config.project.root;
|
|
314
|
+
|
|
315
|
+
if (watch) {
|
|
316
|
+
process.stderr.write(
|
|
317
|
+
"⚠️ --watch is deprecated. Use 'legio dashboard' for live monitoring.\n\n",
|
|
318
|
+
);
|
|
319
|
+
// Polling loop (kept for one release cycle)
|
|
320
|
+
while (true) {
|
|
321
|
+
// Clear screen
|
|
322
|
+
process.stdout.write("\x1b[2J\x1b[H");
|
|
323
|
+
const data = await gatherStatus(root, agentName, verbose);
|
|
324
|
+
if (json) {
|
|
325
|
+
process.stdout.write(`${JSON.stringify(data, null, "\t")}\n`);
|
|
326
|
+
} else {
|
|
327
|
+
printStatus(data);
|
|
328
|
+
}
|
|
329
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
330
|
+
}
|
|
331
|
+
} else {
|
|
332
|
+
const data = await gatherStatus(root, agentName, verbose);
|
|
333
|
+
if (json) {
|
|
334
|
+
process.stdout.write(`${JSON.stringify(data, null, "\t")}\n`);
|
|
335
|
+
} else {
|
|
336
|
+
printStatus(data);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tests for legio stop command.
|
|
3
|
+
*
|
|
4
|
+
* Uses real temp directories, real git repos, and real SQLite session stores.
|
|
5
|
+
* Tmux operations are injected via StopDeps._tmux DI to avoid real tmux calls
|
|
6
|
+
* in CI (real tmux would interfere with developer sessions).
|
|
7
|
+
*
|
|
8
|
+
* WHY DI instead of mock.module: mock.module() in vitest is process-global
|
|
9
|
+
* and leaks across test files. DI keeps mocks scoped to each test invocation.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { mkdir, writeFile } from "node:fs/promises";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
|
15
|
+
import { openSessionStore } from "../sessions/compat.ts";
|
|
16
|
+
import { cleanupTempDir, createTempGitRepo } from "../test-helpers.ts";
|
|
17
|
+
import type { AgentSession } from "../types.ts";
|
|
18
|
+
import { type StopDeps, stopCommand } from "./stop.ts";
|
|
19
|
+
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Fake tmux helper
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
24
|
+
interface TmuxCallTracker {
|
|
25
|
+
isSessionAlive: Array<{ name: string; result: boolean }>;
|
|
26
|
+
killSession: Array<{ name: string }>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Build a fake tmux DI that tracks calls and reports sessions as alive/dead. */
|
|
30
|
+
function makeFakeTmux(aliveMap: Record<string, boolean> = {}): {
|
|
31
|
+
tmux: NonNullable<StopDeps["_tmux"]>;
|
|
32
|
+
calls: TmuxCallTracker;
|
|
33
|
+
} {
|
|
34
|
+
const calls: TmuxCallTracker = { isSessionAlive: [], killSession: [] };
|
|
35
|
+
const tmux: NonNullable<StopDeps["_tmux"]> = {
|
|
36
|
+
isSessionAlive: async (name: string): Promise<boolean> => {
|
|
37
|
+
const alive = aliveMap[name] ?? false;
|
|
38
|
+
calls.isSessionAlive.push({ name, result: alive });
|
|
39
|
+
return alive;
|
|
40
|
+
},
|
|
41
|
+
killSession: async (name: string): Promise<void> => {
|
|
42
|
+
calls.killSession.push({ name });
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
return { tmux, calls };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Test fixtures
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
let tempDir: string;
|
|
53
|
+
let legioDir: string;
|
|
54
|
+
const originalCwd = process.cwd();
|
|
55
|
+
|
|
56
|
+
/** Make a minimal AgentSession for inserting into the store. */
|
|
57
|
+
function makeSession(overrides: Partial<AgentSession> = {}): AgentSession {
|
|
58
|
+
return {
|
|
59
|
+
id: `session-${Date.now()}-${Math.random()}`,
|
|
60
|
+
agentName: "test-builder",
|
|
61
|
+
capability: "builder",
|
|
62
|
+
worktreePath: join(tempDir, ".legio", "worktrees", "test-builder"),
|
|
63
|
+
branchName: "legio/test-builder/legio-abc1",
|
|
64
|
+
beadId: "legio-abc1",
|
|
65
|
+
tmuxSession: "legio-test-project-test-builder",
|
|
66
|
+
state: "working",
|
|
67
|
+
pid: null,
|
|
68
|
+
parentAgent: null,
|
|
69
|
+
depth: 2,
|
|
70
|
+
runId: null,
|
|
71
|
+
startedAt: new Date().toISOString(),
|
|
72
|
+
lastActivity: new Date().toISOString(),
|
|
73
|
+
escalationLevel: 0,
|
|
74
|
+
stalledSince: null,
|
|
75
|
+
...overrides,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Capture stdout output during an async call. */
|
|
80
|
+
async function captureStdout(fn: () => Promise<void>): Promise<string> {
|
|
81
|
+
const chunks: string[] = [];
|
|
82
|
+
const orig = process.stdout.write;
|
|
83
|
+
process.stdout.write = ((chunk: unknown) => {
|
|
84
|
+
chunks.push(String(chunk));
|
|
85
|
+
return true;
|
|
86
|
+
}) as typeof process.stdout.write;
|
|
87
|
+
try {
|
|
88
|
+
await fn();
|
|
89
|
+
} finally {
|
|
90
|
+
process.stdout.write = orig;
|
|
91
|
+
}
|
|
92
|
+
return chunks.join("");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
// Setup / teardown
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
|
|
99
|
+
beforeEach(async () => {
|
|
100
|
+
process.chdir(originalCwd);
|
|
101
|
+
tempDir = await createTempGitRepo();
|
|
102
|
+
legioDir = join(tempDir, ".legio");
|
|
103
|
+
await mkdir(legioDir, { recursive: true });
|
|
104
|
+
|
|
105
|
+
// Minimal config.yaml so loadConfig succeeds
|
|
106
|
+
await writeFile(
|
|
107
|
+
join(legioDir, "config.yaml"),
|
|
108
|
+
["project:", " name: test-project", ` root: ${tempDir}`, " canonicalBranch: main"].join(
|
|
109
|
+
"\n",
|
|
110
|
+
),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
process.chdir(tempDir);
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
afterEach(async () => {
|
|
117
|
+
process.chdir(originalCwd);
|
|
118
|
+
await cleanupTempDir(tempDir);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Tests
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
|
|
125
|
+
describe("stopCommand — help", () => {
|
|
126
|
+
it("prints help for --help", async () => {
|
|
127
|
+
const { tmux } = makeFakeTmux();
|
|
128
|
+
const out = await captureStdout(async () => {
|
|
129
|
+
await stopCommand(["--help"], { _tmux: tmux, _projectRoot: tempDir });
|
|
130
|
+
});
|
|
131
|
+
expect(out).toContain("legio stop");
|
|
132
|
+
expect(out).toContain("--agent");
|
|
133
|
+
expect(out).toContain("--json");
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("prints help for -h", async () => {
|
|
137
|
+
const { tmux } = makeFakeTmux();
|
|
138
|
+
const out = await captureStdout(async () => {
|
|
139
|
+
await stopCommand(["-h"], { _tmux: tmux, _projectRoot: tempDir });
|
|
140
|
+
});
|
|
141
|
+
expect(out).toContain("legio stop");
|
|
142
|
+
});
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
describe("stopCommand — nothing to stop", () => {
|
|
146
|
+
it("prints Nothing to stop when no active sessions", async () => {
|
|
147
|
+
const { tmux, calls } = makeFakeTmux();
|
|
148
|
+
const out = await captureStdout(async () => {
|
|
149
|
+
await stopCommand([], { _tmux: tmux, _projectRoot: tempDir });
|
|
150
|
+
});
|
|
151
|
+
expect(out).toContain("Nothing to stop");
|
|
152
|
+
expect(calls.killSession).toHaveLength(0);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it("outputs JSON with nothingToStop=true and empty stopped array", async () => {
|
|
156
|
+
const { tmux } = makeFakeTmux();
|
|
157
|
+
const out = await captureStdout(async () => {
|
|
158
|
+
await stopCommand(["--json"], { _tmux: tmux, _projectRoot: tempDir });
|
|
159
|
+
});
|
|
160
|
+
const parsed = JSON.parse(out.trim()) as { stopped: string[]; nothingToStop: boolean };
|
|
161
|
+
expect(parsed.nothingToStop).toBe(true);
|
|
162
|
+
expect(parsed.stopped).toHaveLength(0);
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("stopCommand — single session", () => {
|
|
167
|
+
it("kills a live tmux session and marks it completed", async () => {
|
|
168
|
+
// Insert a session into the real store
|
|
169
|
+
const { store } = openSessionStore(legioDir);
|
|
170
|
+
const session = makeSession({
|
|
171
|
+
agentName: "my-builder",
|
|
172
|
+
tmuxSession: "legio-test-project-my-builder",
|
|
173
|
+
});
|
|
174
|
+
store.upsert(session);
|
|
175
|
+
store.close();
|
|
176
|
+
|
|
177
|
+
const { tmux, calls } = makeFakeTmux({ "legio-test-project-my-builder": true });
|
|
178
|
+
|
|
179
|
+
const out = await captureStdout(async () => {
|
|
180
|
+
await stopCommand([], { _tmux: tmux, _projectRoot: tempDir });
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
expect(calls.killSession).toHaveLength(1);
|
|
184
|
+
expect(calls.killSession[0]?.name).toBe("legio-test-project-my-builder");
|
|
185
|
+
expect(out).toContain("my-builder");
|
|
186
|
+
expect(out).toContain("Stopped 1 agent");
|
|
187
|
+
|
|
188
|
+
// Verify session is marked completed
|
|
189
|
+
const { store: store2 } = openSessionStore(legioDir);
|
|
190
|
+
const updated = store2.getByName("my-builder");
|
|
191
|
+
store2.close();
|
|
192
|
+
expect(updated?.state).toBe("completed");
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("skips killSession when tmux session is already dead", async () => {
|
|
196
|
+
const { store } = openSessionStore(legioDir);
|
|
197
|
+
store.upsert(makeSession({ agentName: "dead-builder", tmuxSession: "legio-dead" }));
|
|
198
|
+
store.close();
|
|
199
|
+
|
|
200
|
+
const { tmux, calls } = makeFakeTmux({ "legio-dead": false }); // dead
|
|
201
|
+
|
|
202
|
+
await captureStdout(async () => {
|
|
203
|
+
await stopCommand([], { _tmux: tmux, _projectRoot: tempDir });
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
expect(calls.isSessionAlive).toHaveLength(1);
|
|
207
|
+
expect(calls.killSession).toHaveLength(0); // skip kill for dead sessions
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("outputs JSON with stopped agent name", async () => {
|
|
211
|
+
const { store } = openSessionStore(legioDir);
|
|
212
|
+
store.upsert(makeSession({ agentName: "json-builder", tmuxSession: "legio-json" }));
|
|
213
|
+
store.close();
|
|
214
|
+
|
|
215
|
+
const { tmux } = makeFakeTmux({ "legio-json": true });
|
|
216
|
+
const out = await captureStdout(async () => {
|
|
217
|
+
await stopCommand(["--json"], { _tmux: tmux, _projectRoot: tempDir });
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
const parsed = JSON.parse(out.trim()) as { stopped: string[]; nothingToStop: boolean };
|
|
221
|
+
expect(parsed.nothingToStop).toBe(false);
|
|
222
|
+
expect(parsed.stopped).toContain("json-builder");
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe("stopCommand — deepest-first ordering", () => {
|
|
227
|
+
it("kills deeper sessions before shallower ones", async () => {
|
|
228
|
+
const { store } = openSessionStore(legioDir);
|
|
229
|
+
// Insert a lead (depth=1) and a builder (depth=2) — builder should die first
|
|
230
|
+
store.upsert(
|
|
231
|
+
makeSession({
|
|
232
|
+
agentName: "my-lead",
|
|
233
|
+
depth: 1,
|
|
234
|
+
tmuxSession: "legio-lead",
|
|
235
|
+
}),
|
|
236
|
+
);
|
|
237
|
+
store.upsert(
|
|
238
|
+
makeSession({
|
|
239
|
+
agentName: "my-builder",
|
|
240
|
+
depth: 2,
|
|
241
|
+
tmuxSession: "legio-builder",
|
|
242
|
+
}),
|
|
243
|
+
);
|
|
244
|
+
store.close();
|
|
245
|
+
|
|
246
|
+
const killOrder: string[] = [];
|
|
247
|
+
const tmux: NonNullable<StopDeps["_tmux"]> = {
|
|
248
|
+
isSessionAlive: async () => true,
|
|
249
|
+
killSession: async (name: string) => {
|
|
250
|
+
killOrder.push(name);
|
|
251
|
+
},
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
await captureStdout(async () => {
|
|
255
|
+
await stopCommand([], { _tmux: tmux, _projectRoot: tempDir });
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
// Builder (depth 2) should die before lead (depth 1)
|
|
259
|
+
expect(killOrder[0]).toBe("legio-builder");
|
|
260
|
+
expect(killOrder[1]).toBe("legio-lead");
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("handles sessions at equal depth in any order", async () => {
|
|
264
|
+
const { store } = openSessionStore(legioDir);
|
|
265
|
+
store.upsert(makeSession({ agentName: "builder-a", depth: 2, tmuxSession: "legio-a" }));
|
|
266
|
+
store.upsert(makeSession({ agentName: "builder-b", depth: 2, tmuxSession: "legio-b" }));
|
|
267
|
+
store.close();
|
|
268
|
+
|
|
269
|
+
const killOrder: string[] = [];
|
|
270
|
+
const tmux: NonNullable<StopDeps["_tmux"]> = {
|
|
271
|
+
isSessionAlive: async () => true,
|
|
272
|
+
killSession: async (name: string) => {
|
|
273
|
+
killOrder.push(name);
|
|
274
|
+
},
|
|
275
|
+
};
|
|
276
|
+
|
|
277
|
+
await captureStdout(async () => {
|
|
278
|
+
await stopCommand([], { _tmux: tmux, _projectRoot: tempDir });
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
// Both should be killed, order within same depth is unspecified
|
|
282
|
+
expect(killOrder).toHaveLength(2);
|
|
283
|
+
expect(killOrder).toContain("legio-a");
|
|
284
|
+
expect(killOrder).toContain("legio-b");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
it("correctly orders 3 levels deep", async () => {
|
|
288
|
+
const { store } = openSessionStore(legioDir);
|
|
289
|
+
store.upsert(makeSession({ agentName: "coord", depth: 0, tmuxSession: "legio-coord" }));
|
|
290
|
+
store.upsert(makeSession({ agentName: "lead", depth: 1, tmuxSession: "legio-lead" }));
|
|
291
|
+
store.upsert(makeSession({ agentName: "builder", depth: 2, tmuxSession: "legio-builder" }));
|
|
292
|
+
store.close();
|
|
293
|
+
|
|
294
|
+
const killOrder: string[] = [];
|
|
295
|
+
const tmux: NonNullable<StopDeps["_tmux"]> = {
|
|
296
|
+
isSessionAlive: async () => true,
|
|
297
|
+
killSession: async (name: string) => {
|
|
298
|
+
killOrder.push(name);
|
|
299
|
+
},
|
|
300
|
+
};
|
|
301
|
+
|
|
302
|
+
await captureStdout(async () => {
|
|
303
|
+
await stopCommand([], { _tmux: tmux, _projectRoot: tempDir });
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
expect(killOrder[0]).toBe("legio-builder");
|
|
307
|
+
expect(killOrder[1]).toBe("legio-lead");
|
|
308
|
+
expect(killOrder[2]).toBe("legio-coord");
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
describe("stopCommand — --agent filter", () => {
|
|
313
|
+
it("stops only the specified agent when --agent is given", async () => {
|
|
314
|
+
const { store } = openSessionStore(legioDir);
|
|
315
|
+
store.upsert(makeSession({ agentName: "target-agent", tmuxSession: "legio-target" }));
|
|
316
|
+
store.upsert(makeSession({ agentName: "other-agent", tmuxSession: "legio-other" }));
|
|
317
|
+
store.close();
|
|
318
|
+
|
|
319
|
+
const { tmux, calls } = makeFakeTmux({
|
|
320
|
+
"legio-target": true,
|
|
321
|
+
"legio-other": true,
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
const out = await captureStdout(async () => {
|
|
325
|
+
await stopCommand(["--agent", "target-agent"], { _tmux: tmux, _projectRoot: tempDir });
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
// Only target should be killed
|
|
329
|
+
expect(calls.killSession).toHaveLength(1);
|
|
330
|
+
expect(calls.killSession[0]?.name).toBe("legio-target");
|
|
331
|
+
expect(out).toContain("target-agent");
|
|
332
|
+
});
|
|
333
|
+
|
|
334
|
+
it("throws AgentError when --agent specifies a non-existent agent", async () => {
|
|
335
|
+
const { tmux } = makeFakeTmux();
|
|
336
|
+
await expect(
|
|
337
|
+
stopCommand(["--agent", "nonexistent"], { _tmux: tmux, _projectRoot: tempDir }),
|
|
338
|
+
).rejects.toThrow("No session found for agent 'nonexistent'");
|
|
339
|
+
});
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
describe("stopCommand — completed sessions not stopped again", () => {
|
|
343
|
+
it("does not stop sessions already in completed state", async () => {
|
|
344
|
+
const { store } = openSessionStore(legioDir);
|
|
345
|
+
// Insert a completed session — should not appear in getActive()
|
|
346
|
+
store.upsert(makeSession({ agentName: "done-agent", state: "completed" }));
|
|
347
|
+
store.close();
|
|
348
|
+
|
|
349
|
+
const { tmux, calls } = makeFakeTmux();
|
|
350
|
+
const out = await captureStdout(async () => {
|
|
351
|
+
await stopCommand([], { _tmux: tmux, _projectRoot: tempDir });
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
expect(calls.killSession).toHaveLength(0);
|
|
355
|
+
expect(out).toContain("Nothing to stop");
|
|
356
|
+
});
|
|
357
|
+
});
|